Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-filelock for openSUSE:Factory
checked in at 2026-06-29 17:28:38
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-filelock (Old)
and /work/SRC/openSUSE:Factory/.python-filelock.new.11887 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-filelock"
Mon Jun 29 17:28:38 2026 rev:32 rq:1361999 version:3.29.4
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-filelock/python-filelock.changes
2026-05-06 19:18:26.338424583 +0200
+++
/work/SRC/openSUSE:Factory/.python-filelock.new.11887/python-filelock.changes
2026-06-29 17:28:48.846876017 +0200
@@ -1,0 +2,25 @@
+Sat Jun 27 15:15:17 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 3.29.4:
+ * verify inode in break_lock_file before unlinking a stale lock
+ * keep the read/write heartbeat alive on a transient touch
+ error
+- update to 3.29.3:
+ * ci(release): publish to PyPI on tag push
+ * validate pid range in _parse_lock_holder
+ * fix(ci): restore release environment on tag job
+ * fix(ci): publish from release.yaml on tag push
+ * **Full Changelog**: https://github.com/tox-
+ dev/filelock/compare/3.29.2...3.29.3
+- update to 3.29.2:
+ * open marker reads non-blocking to refuse attacker-placed fifo
+ * fix(soft): harden stale-lock breaking and self-heal
+ malformed locks
+ * check hostname in is_lock_held_by_us
+- update to 3.29.1:
+ * docs: fix API docs of `release()`
+ * docs: clarify per-thread scope of FileLock configuration
+ * fix(soft): refuse to follow symlinks when reading the lock
+ file
+
+-------------------------------------------------------------------
Old:
----
filelock-3.29.0.tar.gz
New:
----
filelock-3.29.4.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-filelock.spec ++++++
--- /var/tmp/diff_new_pack.xVU0tM/_old 2026-06-29 17:28:49.362893531 +0200
+++ /var/tmp/diff_new_pack.xVU0tM/_new 2026-06-29 17:28:49.366893666 +0200
@@ -27,7 +27,7 @@
%endif
%{?sle15_python_module_pythons}
Name: python-filelock%{?pkg_suffix}
-Version: 3.29.0
+Version: 3.29.4
Release: 0
Summary: Platform Independent File Lock in Python
License: MIT
++++++ filelock-3.29.0.tar.gz -> filelock-3.29.4.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/PKG-INFO new/filelock-3.29.4/PKG-INFO
--- old/filelock-3.29.0/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/filelock-3.29.4/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: filelock
-Version: 3.29.0
+Version: 3.29.4
Summary: A platform independent file lock.
Project-URL: Documentation, https://py-filelock.readthedocs.io
Project-URL: Homepage, https://github.com/tox-dev/py-filelock
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/pyproject.toml
new/filelock-3.29.4/pyproject.toml
--- old/filelock-3.29.0/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
+++ new/filelock-3.29.4/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
@@ -101,13 +101,13 @@
]
[tool.hatch]
+version.source = "vcs"
build.hooks.vcs.version-file = "src/filelock/version.py"
build.targets.sdist.include = [
"/src",
"/tests",
"/tox.toml",
]
-version.source = "vcs"
[tool.ruff]
line-length = 120
@@ -147,6 +147,7 @@
"PLR0913", # too many arguments in function definition
"PLR0917", # too many positional arguments
"PLR2004", # Magic value used in comparison, consider replacing with a
constant variable
+ "PLW0717", # try/finally cleanup blocks in tests legitimately hold many
statements
"S101", # asserts allowed in tests
"S603", # `subprocess` call: check for execution of untrusted input
"SLF001", # accessing private members acceptable in tests
@@ -160,29 +161,29 @@
[tool.codespell]
builtin = "clear,usage,en-GB_to_en-US"
+ignore-words-list = "master"
count = true
quiet-level = 3
-ignore-words-list = "master"
[tool.pyproject-fmt]
max_supported_python = "3.14"
[tool.ty]
-environment.python-version = "3.14"
src.exclude = []
+environment.python-version = "3.14"
[tool.pytest]
-ini_options.asyncio_default_fixture_loop_scope = "session"
ini_options.testpaths = [
"tests",
]
-ini_options.timeout = 20
-ini_options.verbosity_assertions = 2
ini_options.filterwarnings = [
"error",
"ignore:unclosed database in <sqlite3.Connection object at:ResourceWarning",
"ignore:unclosed file <_io.TextIOWrapper:ResourceWarning",
]
+ini_options.verbosity_assertions = 2
+ini_options.asyncio_default_fixture_loop_scope = "session"
+ini_options.timeout = 20
[tool.coverage]
run.concurrency = [
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/src/filelock/_api.py
new/filelock-3.29.4/src/filelock/_api.py
--- old/filelock-3.29.0/src/filelock/_api.py 2020-02-02 01:00:00.000000000
+0100
+++ new/filelock-3.29.4/src/filelock/_api.py 2020-02-02 01:00:00.000000000
+0100
@@ -4,7 +4,6 @@
import inspect
import logging
import os
-import pathlib
import sys
import time
import warnings
@@ -15,6 +14,7 @@
from weakref import WeakValueDictionary
from ._error import Timeout
+from ._util import break_lock_file
#: Sentinel indicating that no explicit file permission mode was passed.
#: When used, lock files are created with 0o666 (letting umask and default
ACLs control the final permissions)
@@ -218,7 +218,12 @@
:param mode: file permissions for the lockfile. When not specified,
the OS controls permissions via umask and
default ACLs, preserving POSIX default ACL inheritance in shared
directories.
:param thread_local: Whether this object's internal context should be
thread local or not. If this is set to
- ``False`` then the lock will be reentrant across threads.
+ ``False`` then the lock will be reentrant across threads. When
``True`` (the default), **all fields of the
+ lock's internal context are per-thread**, including the
configuration values ``poll_interval``, ``timeout``,
+ ``blocking``, ``mode``, and ``lifetime``. Setting one of these
properties from one thread does not change
+ the value seen by another thread; threads that did not perform the
write continue to see the value supplied
+ at construction time. If you need configuration values to be
visible across threads, construct the lock
+ with ``thread_local=False``.
:param blocking: whether the lock should be blocking or not
:param is_singleton: If this is set to ``True`` then only one instance
of this class will be created per lock
file. This is useful if you want to use the lock object for
reentrant locking without needing to pass the
@@ -363,11 +368,14 @@
if (lifetime := self._context.lifetime) is None:
return
with contextlib.suppress(OSError):
- if time.time() - pathlib.Path(self.lock_file).stat().st_mtime <
lifetime:
+ # lstat, not stat: an attacker with write access to the lock
directory can replace a held
+ # lock file with a symlink pointing at an old file, making stat()
report the target's stale
+ # mtime so a waiter breaks a live lock and two processes hold it
at once. lstat reads the
+ # symlink's own mtime, matching the O_NOFOLLOW reads elsewhere.
+ st = os.lstat(self.lock_file)
+ if time.time() - st.st_mtime < lifetime:
return
- break_path = f"{self.lock_file}.break.{os.getpid()}"
- pathlib.Path(self.lock_file).rename(break_path)
- pathlib.Path(break_path).unlink()
+ break_lock_file(self.lock_file, st.st_mtime, st.st_ino)
@abstractmethod
def _acquire(self) -> None:
@@ -417,7 +425,7 @@
return True
return False
- def acquire( # noqa: C901
+ def acquire(
self,
timeout: float | None = None,
poll_interval: float | None = None,
@@ -495,26 +503,13 @@
start_time = time.perf_counter()
try:
- while True:
- 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()
- if self.is_locked:
- _LOGGER.debug("Lock %s acquired on %s", lock_id,
lock_filename)
- break
- if self._check_give_up(
- lock_id,
- lock_filename,
- blocking=blocking,
- cancel_check=cancel_check,
- timeout=timeout,
- start_time=start_time,
- ):
- raise Timeout(lock_filename) # noqa: TRY301
- msg = "Lock %s not acquired on %s, waiting %s seconds ..."
- _LOGGER.debug(msg, lock_id, lock_filename, poll_interval)
- time.sleep(poll_interval)
+ self._poll_until_acquired(
+ blocking=blocking,
+ cancel_check=cancel_check,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ start_time=start_time,
+ )
except BaseException:
self._context.lock_counter = max(0, self._context.lock_counter - 1)
if self._context.lock_counter == 0:
@@ -524,10 +519,42 @@
_registry.held[canonical] = lock_id
return AcquireReturnProxy(lock=self)
+ def _poll_until_acquired(
+ self,
+ *,
+ blocking: bool,
+ cancel_check: Callable[[], bool] | None,
+ timeout: float,
+ poll_interval: float,
+ start_time: float,
+ ) -> None:
+ lock_id = id(self)
+ lock_filename = self.lock_file
+ while True:
+ 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()
+ if self.is_locked:
+ _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename)
+ return
+ if self._check_give_up(
+ lock_id,
+ lock_filename,
+ blocking=blocking,
+ cancel_check=cancel_check,
+ timeout=timeout,
+ start_time=start_time,
+ ):
+ raise Timeout(lock_filename)
+ msg = "Lock %s not acquired on %s, waiting %s seconds ..."
+ _LOGGER.debug(msg, lock_id, lock_filename, poll_interval)
+ time.sleep(poll_interval)
+
def release(self, force: bool = False) -> None: # noqa: FBT001, FBT002
"""
Release the file lock. The lock is only completely released when the
lock counter reaches 0. The lock file
- itself is not automatically deleted.
+ itself may be deleted automatically, the behavior is platform-specific.
:param force: If true, the lock counter is ignored and the lock is
released in every case.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/src/filelock/_async_read_write.py
new/filelock-3.29.4/src/filelock/_async_read_write.py
--- old/filelock-3.29.0/src/filelock/_async_read_write.py 2020-02-02
01:00:00.000000000 +0100
+++ new/filelock-3.29.4/src/filelock/_async_read_write.py 2020-02-02
01:00:00.000000000 +0100
@@ -48,7 +48,10 @@
:param blocking: if ``False``, raise :class:`~filelock.Timeout`
immediately when the lock is unavailable
:param is_singleton: if ``True``, reuse existing :class:`ReadWriteLock`
instances for the same resolved path
:param loop: event loop for ``run_in_executor``; ``None`` uses the running
loop
- :param executor: executor for ``run_in_executor``; ``None`` uses the
default executor
+ :param executor: executor for ``run_in_executor``. When ``None`` a
dedicated single-thread executor is created
+ and owned by this lock, ensuring every operation runs on the same
thread (required for SQLite affinity); it
+ is shut down by :meth:`close`. A caller-supplied executor is used
as-is and never shut down here, so when no
+ executor is passed remember to call :meth:`close` to release the owned
one.
.. versionadded:: 3.21.0
@@ -90,8 +93,8 @@
return self._loop
@property
- def executor(self) -> futures.Executor | None:
- """:returns: the executor (or ``None`` for the default)."""
+ def executor(self) -> futures.Executor:
+ """:returns: the executor used for ``run_in_executor`` (a dedicated
single-thread one if none was supplied)."""
return self._executor
async def _run(self, func: Callable[..., object], *args: object, **kwargs:
object) -> object:
@@ -200,6 +203,12 @@
if self._owns_executor:
self._executor.shutdown(wait=False)
+ def __del__(self) -> None:
+ # Safety net: if close() was never called, still shut down the
executor we created so its worker thread does
+ # not outlive the lock. A caller-supplied executor is left untouched.
shutdown(wait=False) never blocks.
+ if getattr(self, "_owns_executor", False):
+ self._executor.shutdown(wait=False)
+
__all__ = [
"AsyncAcquireReadWriteReturnProxy",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/src/filelock/_error.py
new/filelock-3.29.4/src/filelock/_error.py
--- old/filelock-3.29.0/src/filelock/_error.py 2020-02-02 01:00:00.000000000
+0100
+++ new/filelock-3.29.4/src/filelock/_error.py 2020-02-02 01:00:00.000000000
+0100
@@ -1,7 +1,5 @@
from __future__ import annotations
-from typing import Any
-
class Timeout(TimeoutError): # noqa: N818
"""Raised when the lock could not be acquired in *timeout* seconds."""
@@ -10,7 +8,7 @@
super().__init__()
self._lock_file = lock_file
- def __reduce__(self) -> str | tuple[Any, ...]:
+ def __reduce__(self) -> tuple[type[Timeout], tuple[str]]:
return self.__class__, (self._lock_file,) # Properly pickle the
exception
def __str__(self) -> str:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/src/filelock/_read_write.py
new/filelock-3.29.4/src/filelock/_read_write.py
--- old/filelock-3.29.0/src/filelock/_read_write.py 2020-02-02
01:00:00.000000000 +0100
+++ new/filelock-3.29.4/src/filelock/_read_write.py 2020-02-02
01:00:00.000000000 +0100
@@ -168,7 +168,9 @@
if not acquired:
raise Timeout(self.lock_file) from None
- def _validate_reentrant(self, mode: Literal["read", "write"], opposite:
str, direction: str) -> AcquireReturnProxy:
+ def _validate_reentrant(self, mode: Literal["read", "write"]) ->
AcquireReturnProxy:
+ opposite = "write" if mode == "read" else "read"
+ direction = "downgrade" if mode == "read" else "upgrade"
if self._current_mode != mode:
msg = (
f"Cannot acquire {mode} lock on {self.lock_file} (lock id:
{id(self)}): "
@@ -210,31 +212,14 @@
self._con.execute("SELECT name FROM sqlite_schema LIMIT
1;").close()
def _acquire(self, mode: Literal["read", "write"], timeout: float, *,
blocking: bool) -> AcquireReturnProxy:
- opposite = "write" if mode == "read" else "read"
- direction = "downgrade" if mode == "read" else "upgrade"
-
with self._internal_lock:
if self._lock_level > 0:
- return self._validate_reentrant(mode, opposite, direction)
+ return self._validate_reentrant(mode)
start_time = time.perf_counter()
self._acquire_transaction_lock(blocking=blocking, timeout=timeout)
try:
- # Double-check: another thread may have acquired the lock while we
waited on _transaction_lock.
- with self._internal_lock:
- if self._lock_level > 0:
- return self._validate_reentrant(mode, opposite, direction)
-
- self._configure_and_begin(mode, timeout, blocking=blocking,
start_time=start_time)
-
- with self._internal_lock:
- self._current_mode = mode
- self._lock_level = 1
- if mode == "write":
- self._write_thread_id = threading.get_ident()
-
- return AcquireReturnProxy(lock=self)
-
+ return self._do_acquire_inner(mode, timeout, blocking=blocking,
start_time=start_time)
except sqlite3.OperationalError as exc:
if "database is locked" not in str(exc):
raise
@@ -242,6 +227,26 @@
finally:
self._transaction_lock.release()
+ def _do_acquire_inner(
+ self,
+ mode: Literal["read", "write"],
+ timeout: float,
+ *,
+ blocking: bool,
+ start_time: float,
+ ) -> AcquireReturnProxy:
+ # Double-check: another thread may have acquired the lock while we
waited on _transaction_lock.
+ with self._internal_lock:
+ if self._lock_level > 0:
+ return self._validate_reentrant(mode)
+ self._configure_and_begin(mode, timeout, blocking=blocking,
start_time=start_time)
+ with self._internal_lock:
+ self._current_mode = mode
+ self._lock_level = 1
+ if mode == "write":
+ self._write_thread_id = threading.get_ident()
+ return AcquireReturnProxy(lock=self)
+
def acquire_read(self, timeout: float = -1, *, blocking: bool = True) ->
AcquireReturnProxy:
"""
Acquire a shared read lock.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/src/filelock/_soft.py
new/filelock-3.29.4/src/filelock/_soft.py
--- old/filelock-3.29.0/src/filelock/_soft.py 2020-02-02 01:00:00.000000000
+0100
+++ new/filelock-3.29.4/src/filelock/_soft.py 2020-02-02 01:00:00.000000000
+0100
@@ -9,12 +9,13 @@
from pathlib import Path
from ._api import BaseFileLock
-from ._util import ensure_directory_exists, raise_on_not_writable_file
+from ._util import break_lock_file, ensure_directory_exists,
raise_on_not_writable_file
_WIN_SYNCHRONIZE = 0x100000
_WIN_ERROR_INVALID_PARAMETER = 87
_WIN_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
_MALFORMED_LOCK_AGE_THRESHOLD = 2.0
+_MAX_LOCK_FILE_SIZE = 1024
class SoftFileLock(BaseFileLock):
@@ -56,41 +57,30 @@
def _try_break_stale_lock(self) -> None:
with suppress(OSError, ValueError):
- lock_path = Path(self.lock_file)
- stat_result = lock_path.stat()
- content = lock_path.read_text(encoding="utf-8")
- lines = content.strip().splitlines()
-
- if len(lines) not in {2, 3}:
- if time.time() - stat_result.st_mtime >=
_MALFORMED_LOCK_AGE_THRESHOLD:
- self._evict_lock_file()
- return
+ content, mtime, ino = _read_lock_file(self.lock_file)
+ holder = _parse_lock_holder(content)
- pid_str, hostname = lines[0], lines[1]
- creation_time_str = lines[2] if len(lines) == 3 else None # noqa:
PLR2004
+ if holder is None:
+ # Unparsable: wrong line count, a non-integer PID or creation
time, empty, oversized or not UTF-8.
+ # Self-heal only once the file is clearly not a half-written
fresh lock (a peer between O_EXCL and
+ # _write_lock_info), so the brief create-then-write window is
never mistaken for a stale lock.
+ if time.time() - mtime >= _MALFORMED_LOCK_AGE_THRESHOLD:
+ break_lock_file(self.lock_file, mtime, ino)
+ return
+ pid, hostname, creation_time = holder
if hostname != socket.gethostname():
return
- pid = int(pid_str)
-
if self._is_process_alive(pid):
- if sys.platform == "win32" and creation_time_str is not None:
# pragma: win32 cover
- stored = int(creation_time_str)
- actual = self._get_process_creation_time(pid)
- if actual is not None and actual != stored:
- pass # PID recycled, fall through to evict
- else:
- return # same process or can't verify — don't evict
- else:
- return
+ if sys.platform != "win32" or creation_time is None: #
pragma: win32 no cover
+ return # same process, or no creation time to
disambiguate a recycled PID — don't evict
+ actual = self._get_process_creation_time(pid) # pragma: win32
cover
+ if actual is None or actual == creation_time: # pragma: win32
cover
+ return # same process or can't verify — don't evict
+ # else: PID alive but creation time differs — the PID was
recycled, so the lock is stale.
- self._evict_lock_file()
-
- def _evict_lock_file(self) -> None:
- break_path = f"{self.lock_file}.break.{os.getpid()}"
- Path(self.lock_file).rename(break_path)
- Path(break_path).unlink()
+ break_lock_file(self.lock_file, mtime, ino)
@staticmethod
def _is_process_alive(pid: int) -> bool:
@@ -158,13 +148,10 @@
:returns: the PID as an integer, or ``None`` if the lock file does not
exist or cannot be parsed
"""
- try:
- content = Path(self.lock_file).read_text(encoding="utf-8")
- lines = content.strip().splitlines()
- if lines:
- return int(lines[0])
- except (OSError, ValueError):
- pass
+ with suppress(OSError, ValueError):
+ holder = _parse_lock_holder(_read_lock_file(self.lock_file)[0])
+ if holder is not None:
+ return holder[0]
return None
@property
@@ -172,10 +159,15 @@
"""
Whether this lock is held by the current process.
- :returns: ``True`` if the lock file exists and contains the current
process's PID
+ :returns: ``True`` if the lock file exists and names the current
process's PID and hostname
"""
- return self.pid == os.getpid()
+ with suppress(OSError, ValueError):
+ holder = _parse_lock_holder(_read_lock_file(self.lock_file)[0])
+ if holder is not None:
+ pid, hostname, _ = holder
+ return pid == os.getpid() and hostname == socket.gethostname()
+ return False
def break_lock(self) -> None:
"""Forcibly break the lock by removing the lock file, regardless of
who holds it."""
@@ -209,6 +201,43 @@
return
+def _read_lock_file(path: str) -> tuple[str | None, float, int]:
+ # The lock file is created with O_EXCL | O_NOFOLLOW, so a symlink here is
a hostile replacement and must
+ # not be followed. O_NONBLOCK keeps an attacker-placed FIFO from stalling
the open (O_NOFOLLOW alone only
+ # rejects a symlink, not a real FIFO at the path), and the capped read
stops a huge file (e.g. /dev/zero)
+ # from exhausting memory. Content is None when the file is too large or
not UTF-8, but the mtime and inode
+ # still flow back so the caller can evict it as a stale, malformed lock
and verify identity before breaking.
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) |
getattr(os, "O_NONBLOCK", 0))
+ try:
+ st, data = os.fstat(fd), os.read(fd, _MAX_LOCK_FILE_SIZE + 1)
+ finally:
+ os.close(fd)
+ if len(data) <= _MAX_LOCK_FILE_SIZE:
+ with suppress(UnicodeDecodeError):
+ return data.decode("utf-8"), st.st_mtime, st.st_ino
+ return None, st.st_mtime, st.st_ino
+
+
+def _parse_lock_holder(content: str | None) -> tuple[int, str, int | None] |
None:
+ # A well-formed lock file is "<pid>\n<hostname>\n" with an optional
"<creation_time>\n" third line on Windows.
+ # Anything else — wrong line count, a non-integer PID or creation time,
empty or unreadable content — is
+ # unparsable; returning None lets the caller treat it as a malformed lock
to self-heal rather than a holder.
+ if not content or len(lines := content.strip().splitlines()) not in {2, 3}:
+ return None
+ try:
+ pid = int(lines[0])
+ creation_time = int(lines[2]) if len(lines) == 3 else None # noqa:
PLR2004
+ except ValueError:
+ return None
+ # A pid outside the valid range is a malformed lock, not a holder. Without
this, a non-positive pid
+ # reaches os.kill() where 0 / -1 mean "the caller's own process group /
every process" so a dead
+ # holder reads as alive and the lock is never reclaimed, while an
oversized pid raises OverflowError
+ # (not OSError/ValueError) out of the self-heal path. _parse_marker_bytes
already enforces this range.
+ if not 1 <= pid <= 2**31 - 1:
+ return None
+ return pid, lines[1], creation_time
+
+
__all__ = [
"SoftFileLock",
]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/src/filelock/_soft_rw/_sync.py
new/filelock-3.29.4/src/filelock/_soft_rw/_sync.py
--- old/filelock-3.29.0/src/filelock/_soft_rw/_sync.py 2020-02-02
01:00:00.000000000 +0100
+++ new/filelock-3.29.4/src/filelock/_soft_rw/_sync.py 2020-02-02
01:00:00.000000000 +0100
@@ -32,6 +32,7 @@
_BREAK_SUFFIX = ".break"
_MAX_MARKER_SIZE = 1024
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
+_O_NONBLOCK = getattr(os, "O_NONBLOCK", 0)
# dirfd-relative I/O is a Unix-only optimization; Windows cannot ``os.open()``
a directory at all, and
# its ``os`` module skips dir_fd support entirely. When disabled, callers fall
back to full-path ops.
_SUPPORTS_DIR_FD = sys.platform != "win32" and os.open in os.supports_dir_fd
@@ -404,8 +405,8 @@
*,
blocking: bool | None,
) -> AcquireReturnProxy:
- effective_timeout = self.timeout if timeout is None else timeout
- effective_blocking = self.blocking if blocking is None else blocking
+ 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:
@@ -418,55 +419,57 @@
return self._validate_reentrant(mode)
start = time.perf_counter()
- if not effective_blocking:
+ if not blocking:
acquired = self._locks.transaction.acquire(blocking=False)
- elif effective_timeout == -1:
+ elif timeout == -1:
acquired = self._locks.transaction.acquire(blocking=True)
else:
- acquired = self._locks.transaction.acquire(blocking=True,
timeout=effective_timeout)
+ acquired = self._locks.transaction.acquire(blocking=True,
timeout=timeout)
if not acquired:
raise Timeout(self.lock_file) from None
try:
- with self._locks.internal:
- if self._hold is not None:
- return self._validate_reentrant(mode)
-
- deadline = None if effective_timeout == -1 else start +
effective_timeout
- token = secrets.token_hex(16)
- if mode == "write":
- marker_name, is_reader = self._acquire_writer_slot(
- token, deadline=deadline, blocking=effective_blocking
- )
- else:
- marker_name, is_reader = self._acquire_reader_slot(
- token, deadline=deadline, blocking=effective_blocking
- )
-
- stop_event = threading.Event()
- heartbeat = _HeartbeatThread(
- refresh=self._refresh_marker,
- interval=self.heartbeat_interval,
- stop_event=stop_event,
- name=f"filelock-heartbeat-{id(self):x}",
- )
-
- with self._locks.internal:
- self._hold = _Hold(
- level=1,
- mode=mode,
- write_thread_id=threading.get_ident() if mode == "write"
else None,
- marker_name=marker_name,
- is_reader=is_reader,
- token=token,
- heartbeat_thread=heartbeat,
- heartbeat_stop=stop_event,
- )
-
- heartbeat.start()
- return AcquireReturnProxy(lock=self)
+ return self._do_acquire_inner(mode, timeout, start,
blocking=blocking)
finally:
self._locks.transaction.release()
+ def _do_acquire_inner(
+ self,
+ mode: _Mode,
+ effective_timeout: float,
+ start: float,
+ *,
+ blocking: bool,
+ ) -> AcquireReturnProxy:
+ with self._locks.internal:
+ if self._hold is not None:
+ return self._validate_reentrant(mode)
+ deadline = None if effective_timeout == -1 else start +
effective_timeout
+ token = secrets.token_hex(16)
+ if mode == "write":
+ marker_name, is_reader = self._acquire_writer_slot(token,
deadline=deadline, blocking=blocking)
+ else:
+ marker_name, is_reader = self._acquire_reader_slot(token,
deadline=deadline, blocking=blocking)
+ stop_event = threading.Event()
+ heartbeat = _HeartbeatThread(
+ refresh=self._refresh_marker,
+ interval=self.heartbeat_interval,
+ stop_event=stop_event,
+ name=f"filelock-heartbeat-{id(self):x}",
+ )
+ with self._locks.internal:
+ self._hold = _Hold(
+ level=1,
+ mode=mode,
+ write_thread_id=threading.get_ident() if mode == "write" else
None,
+ marker_name=marker_name,
+ is_reader=is_reader,
+ token=token,
+ heartbeat_thread=heartbeat,
+ heartbeat_stop=stop_event,
+ )
+ heartbeat.start()
+ return AcquireReturnProxy(lock=self)
+
def _validate_reentrant(self, mode: _Mode) -> AcquireReturnProxy:
hold = self._hold
assert hold is not None # noqa: S101
@@ -638,10 +641,17 @@
# thread so it does not touch a stranger's file.
if info is None or not hmac.compare_digest(info.token, token):
return False
+ # A transient touch failure (ESTALE / EIO on the NFS-style filesystems
this lock targets) must not
+ # kill the heartbeat thread: the read above just confirmed the marker
is still ours, so swallow the
+ # error and retry on the next tick rather than letting the lease lapse
while we still believe we
+ # hold the lock. FileNotFoundError is different in kind -- the marker
we just read has since been
+ # unlinked, i.e. a peer evicted us -- so stop the heartbeat at once
instead of waiting a tick.
try:
_touch(marker_name, dir_fd=dir_fd)
- except FileNotFoundError: # pragma: no cover - race between
successful read and touch
+ except FileNotFoundError:
return False
+ except OSError:
+ pass
return True
def _reset_after_fork_in_child(self) -> None: # pragma: no cover - fork
child not tracked
@@ -694,9 +704,10 @@
def _read_marker(name: str, *, dir_fd: int | None = None) -> tuple[_MarkerInfo
| None, float] | None:
- # O_NOFOLLOW is defense in depth: we already created this file, but a
hostile replacement by symlink
- # between create and read would be caught here.
- flags = os.O_RDONLY | _O_NOFOLLOW
+ # The file is ours; these guard a hostile mid-flight swap. O_NOFOLLOW
rejects a symlink; O_NONBLOCK keeps
+ # a real FIFO from blocking the open forever, so it reads as a malformed
marker instead of wedging a peer
+ # that holds the state lock.
+ flags = os.O_RDONLY | _O_NOFOLLOW | _O_NONBLOCK
try:
fd = os.open(name, flags, dir_fd=dir_fd) if _SUPPORTS_DIR_FD and
dir_fd is not None else os.open(name, flags)
except OSError:
@@ -705,7 +716,7 @@
try:
st = os.fstat(fd)
data = os.read(fd, _MAX_MARKER_SIZE + 1)
- except OSError: # pragma: no cover - fstat/read after a successful
open is hard to provoke
+ except OSError: # pragma: no cover - e.g. EAGAIN from a hostile FIFO
that has a writer attached
return None
finally:
os.close(fd)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/src/filelock/_util.py
new/filelock-3.29.4/src/filelock/_util.py
--- old/filelock-3.29.0/src/filelock/_util.py 2020-02-02 01:00:00.000000000
+0100
+++ new/filelock-3.29.4/src/filelock/_util.py 2020-02-02 01:00:00.000000000
+0100
@@ -47,7 +47,40 @@
Path(filename).parent.mkdir(parents=True, exist_ok=True)
+def break_lock_file(lock_file: str, mtime_before: float, ino_before: int) ->
None:
+ """
+ Atomically break a stale lock file that was judged stale at modification
time *mtime_before*.
+
+ The file is renamed to a process-private name before being unlinked, so
two processes breaking the same lock
+ cannot delete each other's work (only one rename of a given inode
succeeds; the loser gets ``OSError``). After the
+ rename the file is re-checked: a newer modification time, or a different
inode than *ino_before*, means a peer
+ recreated the lock between the stale decision and the rename, so we
grabbed a live file and must abort, leaving the
+ renamed file in place rather than rolling back (a rollback rename is
itself racy — same trade-off as the soft
+ read/write marker break). The inode check matters because filesystems with
coarse modification-time granularity
+ (NFS, FAT) can give a same-second recreation the old mtime, so mtime alone
would not catch it and a live lock would
+ be unlinked; the inode is the reliable identity, mirroring the token
re-check in the soft read/write marker break.
+ ``lstat`` is used so a hostile symlink swapped in after the decision is
not followed.
+
+ :param lock_file: path to the lock file to break.
+ :param mtime_before: modification time observed when the lock was judged
stale.
+ :param ino_before: inode number observed when the lock was judged stale.
+
+ :raises OSError: if the rename fails (e.g. the file vanished or is not
owned in a sticky directory).
+
+ """
+ break_path = f"{lock_file}.break.{os.getpid()}"
+ Path(lock_file).rename(break_path)
+ try:
+ st_after = os.lstat(break_path)
+ except OSError:
+ return
+ if st_after.st_mtime > mtime_before or st_after.st_ino != ino_before:
+ return
+ Path(break_path).unlink()
+
+
__all__ = [
+ "break_lock_file",
"ensure_directory_exists",
"raise_on_not_writable_file",
]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/src/filelock/asyncio.py
new/filelock-3.29.4/src/filelock/asyncio.py
--- old/filelock-3.29.0/src/filelock/asyncio.py 2020-02-02 01:00:00.000000000
+0100
+++ new/filelock-3.29.4/src/filelock/asyncio.py 2020-02-02 01:00:00.000000000
+0100
@@ -139,7 +139,12 @@
:param mode: file permissions for the lockfile. When not specified,
the OS controls permissions via umask and
default ACLs, preserving POSIX default ACL inheritance in shared
directories.
:param thread_local: Whether this object's internal context should be
thread local or not. If this is set to
- ``False`` then the lock will be reentrant across threads.
+ ``False`` then the lock will be reentrant across threads. When
``True`` (the default), **all fields of the
+ lock's internal context are per-thread**, including the
configuration values ``poll_interval``, ``timeout``,
+ ``blocking``, ``mode``, and ``lifetime``. Setting one of these
properties from one thread does not change
+ the value seen by another thread; threads that did not perform the
write continue to see the value supplied
+ at construction time. If you need configuration values to be
visible across threads, construct the lock
+ with ``thread_local=False``.
:param blocking: whether the lock should be blocking or not
:param is_singleton: If this is set to ``True`` then only one instance
of this class will be created per lock
file. This is useful if you want to use the lock object for
reentrant locking without needing to pass the
@@ -251,39 +256,56 @@
# Increment the number right at the beginning. We can still undo it,
if something fails.
self._context.lock_counter += 1
- lock_id = id(self)
- lock_filename = self.lock_file
start_time = time.perf_counter()
try:
- while True:
- 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)
- if self.is_locked:
- _LOGGER.debug("Lock %s acquired on %s", lock_id,
lock_filename)
- break
- if self._check_give_up(
- lock_id,
- lock_filename,
- blocking=blocking,
- cancel_check=cancel_check,
- timeout=timeout,
- start_time=start_time,
- ):
- raise Timeout(lock_filename) # noqa: TRY301
- msg = "Lock %s not acquired on %s, waiting %s seconds ..."
- _LOGGER.debug(msg, lock_id, lock_filename, poll_interval)
- await asyncio.sleep(poll_interval)
+ await self._async_poll_until_acquired(
+ blocking=blocking,
+ cancel_check=cancel_check,
+ timeout=timeout,
+ poll_interval=poll_interval,
+ start_time=start_time,
+ )
except BaseException: # Something did go wrong, so decrement the
counter.
self._context.lock_counter = max(0, self._context.lock_counter - 1)
raise
return AsyncAcquireReturnProxy(lock=self)
+ async def _async_poll_until_acquired(
+ self,
+ *,
+ blocking: bool,
+ cancel_check: Callable[[], bool] | None,
+ timeout: float,
+ poll_interval: float,
+ start_time: float,
+ ) -> None:
+ lock_id = id(self)
+ lock_filename = self.lock_file
+ while True:
+ 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)
+ if self.is_locked:
+ _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename)
+ return
+ if self._check_give_up(
+ lock_id,
+ lock_filename,
+ blocking=blocking,
+ cancel_check=cancel_check,
+ timeout=timeout,
+ start_time=start_time,
+ ):
+ raise Timeout(lock_filename)
+ msg = "Lock %s not acquired on %s, waiting %s seconds ..."
+ _LOGGER.debug(msg, lock_id, lock_filename, poll_interval)
+ await asyncio.sleep(poll_interval)
+
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
- itself is not automatically deleted.
+ itself may be deleted automatically, the behavior is platform-specific.
:param force: If true, the lock counter is ignored and the lock is
released in every case.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/src/filelock/version.py
new/filelock-3.29.4/src/filelock/version.py
--- old/filelock-3.29.0/src/filelock/version.py 2020-02-02 01:00:00.000000000
+0100
+++ new/filelock-3.29.4/src/filelock/version.py 2020-02-02 01:00:00.000000000
+0100
@@ -18,7 +18,7 @@
commit_id: str | None
__commit_id__: str | None
-__version__ = version = '3.29.0'
-__version_tuple__ = version_tuple = (3, 29, 0)
+__version__ = version = '3.29.4'
+__version_tuple__ = version_tuple = (3, 29, 4)
__commit_id__ = commit_id = None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/tests/soft_rw/test_soft_rw_sync.py
new/filelock-3.29.4/tests/soft_rw/test_soft_rw_sync.py
--- old/filelock-3.29.0/tests/soft_rw/test_soft_rw_sync.py 2020-02-02
01:00:00.000000000 +0100
+++ new/filelock-3.29.4/tests/soft_rw/test_soft_rw_sync.py 2020-02-02
01:00:00.000000000 +0100
@@ -7,6 +7,7 @@
import threading
import time
from contextlib import contextmanager, suppress
+from errno import EIO, ENOENT
from multiprocessing import Event, Process
from pathlib import Path
from typing import TYPE_CHECKING, Literal
@@ -15,6 +16,7 @@
from filelock import Timeout
from filelock._soft_rw import SoftReadWriteLock
+from filelock._soft_rw import _sync as sync_mod
if TYPE_CHECKING:
from collections.abc import Callable, Generator
@@ -607,6 +609,46 @@
lock.close()
+def test_heartbeat_survives_transient_touch_error(lock_file: str, monkeypatch:
pytest.MonkeyPatch) -> None:
+ # On the NFS-style filesystems this lock targets a transient ESTALE / EIO
on the heartbeat touch is
+ # routine; it must not kill the heartbeat and silently drop the lease
while we still believe we hold it.
+ def boom(name: str, *, dir_fd: int | None = None) -> None: # noqa: ARG001
+ raise OSError(EIO, "Input/output error")
+
+ lock = _make_lock(lock_file, heartbeat_interval=0.02, stale_threshold=0.2)
+ lock.acquire_write(timeout=2)
+ try:
+ hold = lock._hold
+ assert hold is not None
+ monkeypatch.setattr(sync_mod, "_touch", boom)
+ time.sleep(0.2) # ~10 ticks, all of which fail the touch
+ assert hold.heartbeat_thread.is_alive()
+ assert not hold.heartbeat_stop.is_set()
+ finally:
+ lock.release(force=True)
+ lock.close()
+
+
+def test_heartbeat_stops_when_marker_unlinked_during_touch(lock_file: str,
monkeypatch: pytest.MonkeyPatch) -> None:
+ # ENOENT on the touch means the marker we just read was unlinked between
the read and the touch -- a peer
+ # evicted us, not a transient hiccup -- so the heartbeat must stop at once
rather than wait another tick.
+ def gone(name: str, *, dir_fd: int | None = None) -> None: # noqa: ARG001
+ raise FileNotFoundError(ENOENT, "No such file or directory")
+
+ lock = _make_lock(lock_file, heartbeat_interval=0.02, stale_threshold=0.2)
+ lock.acquire_write(timeout=2)
+ try:
+ hold = lock._hold
+ assert hold is not None
+ monkeypatch.setattr(sync_mod, "_touch", gone)
+ assert hold.heartbeat_stop.wait(timeout=2)
+ hold.heartbeat_thread.join(timeout=2)
+ assert not hold.heartbeat_thread.is_alive()
+ finally:
+ lock.release(force=True)
+ lock.close()
+
+
@pytest.mark.timeout(15)
def test_live_heartbeat_keeps_lock_alive_past_stale_threshold(lock_file: str)
-> None:
# Generous timing here so the test stays stable on slow Windows runners
where the holder's
@@ -659,6 +701,22 @@
lock = _make_lock(lock_file)
try:
with lock.write_lock(timeout=2):
+ pass
+ finally:
+ lock.close()
+
+
+def test_fifo_write_marker_does_not_block(lock_file: str) -> None:
+ if sys.platform == "win32":
+ pytest.skip("os.mkfifo is unix-only")
+ marker = f"{lock_file}.write"
+ os.mkfifo(marker)
+ past = time.time() - 1000
+ os.utime(marker, (past, past))
+ # Without O_NONBLOCK this open blocks forever; the FIFO instead reads as a
stale marker and is evicted.
+ lock = _make_lock(lock_file)
+ try:
+ with lock.write_lock(timeout=2):
pass
finally:
lock.close()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/tests/test_async_read_write.py
new/filelock-3.29.4/tests/test_async_read_write.py
--- old/filelock-3.29.0/tests/test_async_read_write.py 2020-02-02
01:00:00.000000000 +0100
+++ new/filelock-3.29.4/tests/test_async_read_write.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,5 +1,6 @@
from __future__ import annotations
+import gc
from concurrent.futures import ThreadPoolExecutor
from typing import TYPE_CHECKING
@@ -219,6 +220,46 @@
executor.shutdown(wait=False)
[email protected]
+async def test_close_shuts_down_owned_executor(lock_file: str) -> None:
+ lock = AsyncReadWriteLock(lock_file, is_singleton=False)
+ assert lock._owns_executor is True
+ executor = lock.executor
+ await lock.close()
+ with pytest.raises(RuntimeError): # submitting after shutdown is rejected
+ executor.submit(int)
+
+
[email protected]
+async def test_close_keeps_provided_executor_open(lock_file: str) -> None:
+ executor = ThreadPoolExecutor(max_workers=1)
+ lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor)
+ assert lock._owns_executor is False
+ await lock.close()
+ assert executor.submit(int).result(timeout=5) == 0 # still usable
+ executor.shutdown(wait=False)
+
+
+def test_del_shuts_down_owned_executor(lock_file: str) -> None:
+ lock = AsyncReadWriteLock(lock_file, is_singleton=False)
+ executor = lock.executor
+ lock._lock.close() # close the connection so only the executor lifecycle
is under test
+ del lock
+ gc.collect()
+ with pytest.raises(RuntimeError):
+ executor.submit(int)
+
+
+def test_del_keeps_provided_executor_open(lock_file: str) -> None:
+ executor = ThreadPoolExecutor(max_workers=1)
+ lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor)
+ lock._lock.close()
+ del lock
+ gc.collect()
+ assert executor.submit(int).result(timeout=5) == 0
+ executor.shutdown(wait=False)
+
+
@pytest.mark.asyncio
async def test_acquire_return_proxy_context_manager(lock_file: str) -> None:
lock = AsyncReadWriteLock(lock_file, is_singleton=False)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/tests/test_error.py
new/filelock-3.29.4/tests/test_error.py
--- old/filelock-3.29.0/tests/test_error.py 2020-02-02 01:00:00.000000000
+0100
+++ new/filelock-3.29.4/tests/test_error.py 2020-02-02 01:00:00.000000000
+0100
@@ -20,6 +20,11 @@
assert timeout.lock_file == "/path/to/lock"
+def test_timeout_reduce() -> None:
+ timeout = Timeout("/path/to/lock")
+ assert timeout.__reduce__() == (Timeout, ("/path/to/lock",))
+
+
def test_timeout_pickle() -> None:
timeout = Timeout("/path/to/lock")
timeout_loaded = pickle.loads(pickle.dumps(timeout)) # noqa: S301
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/tests/test_filelock.py
new/filelock-3.29.4/tests/test_filelock.py
--- old/filelock-3.29.0/tests/test_filelock.py 2020-02-02 01:00:00.000000000
+0100
+++ new/filelock-3.29.4/tests/test_filelock.py 2020-02-02 01:00:00.000000000
+0100
@@ -61,8 +61,10 @@
def make_ro(path: Path) -> Iterator[None]:
write = S_IWUSR | S_IWGRP | S_IWOTH
path.chmod(path.stat().st_mode & ~write)
- yield
- path.chmod(path.stat().st_mode | write)
+ try:
+ yield
+ finally:
+ path.chmod(path.stat().st_mode | write)
@pytest.fixture
@@ -739,6 +741,46 @@
lock.release(force=True)
[email protected]("lock_type", [FileLock, SoftFileLock])
+def test_thread_local_setter_visibility(lock_type: type[BaseFileLock],
tmp_path: Path) -> None:
+ """Document that property setters are per-thread when thread_local=True.
+
+ Setting ``poll_interval`` on the constructing thread must not affect the
+ value observed from a different thread. The other thread sees the value
+ supplied to the constructor (``threading.local`` re-applies the original
+ constructor arguments the first time each new thread accesses the
+ context).
+ """
+ lock = lock_type(tmp_path / "x.lock", thread_local=True,
poll_interval=0.05)
+ lock.poll_interval = 0.5
+
+ observed: list[float] = []
+
+ def read_from_thread() -> None:
+ observed.append(lock.poll_interval)
+
+ t = threading.Thread(target=read_from_thread)
+ t.start()
+ t.join()
+
+ # setter is local to the writing thread; reader sees the constructor
default
+ assert observed == [pytest.approx(0.05)]
+
+
[email protected]("lock_type", [FileLock, SoftFileLock])
+def test_non_thread_local_setter_visibility(lock_type: type[BaseFileLock],
tmp_path: Path) -> None:
+ """With thread_local=False, property setters are visible across threads."""
+ lock = lock_type(tmp_path / "x.lock", thread_local=False,
poll_interval=0.05)
+ lock.poll_interval = 0.5
+
+ observed: list[float] = []
+ t = threading.Thread(target=lambda: observed.append(lock.poll_interval))
+ t.start()
+ t.join()
+
+ assert observed == [pytest.approx(0.5)]
+
+
def test_subclass_compatibility(tmp_path: Path) -> None:
class MyFileLock(FileLock):
def __init__(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/tests/test_lock_expiry.py
new/filelock-3.29.4/tests/test_lock_expiry.py
--- old/filelock-3.29.0/tests/test_lock_expiry.py 2020-02-02
01:00:00.000000000 +0100
+++ new/filelock-3.29.4/tests/test_lock_expiry.py 2020-02-02
01:00:00.000000000 +0100
@@ -50,7 +50,7 @@
lock_path.touch()
os.utime(lock_path, (0, 0))
- mocker.patch("filelock._api.pathlib.Path.rename",
side_effect=FileNotFoundError)
+ mocker.patch("filelock._util.Path.rename", side_effect=FileNotFoundError)
lock = SoftFileLock(lock_path, lifetime=0.1, timeout=0.5)
with pytest.raises(TimeoutError):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/tests/test_soft_stale.py
new/filelock-3.29.4/tests/test_soft_stale.py
--- old/filelock-3.29.0/tests/test_soft_stale.py 2020-02-02
01:00:00.000000000 +0100
+++ new/filelock-3.29.4/tests/test_soft_stale.py 2020-02-02
01:00:00.000000000 +0100
@@ -10,6 +10,7 @@
import pytest
from filelock import SoftFileLock
+from filelock._soft import _MAX_LOCK_FILE_SIZE
if TYPE_CHECKING:
from unittest.mock import MagicMock
@@ -19,204 +20,190 @@
unix_only = pytest.mark.skipif(sys.platform == "win32", reason="unix-only
stale lock detection")
win_only = pytest.mark.skipif(sys.platform != "win32", reason="windows-only")
+HOST = socket.gethostname()
+DEAD_PID = 2**22 + 1
-def test_lock_writes_pid_and_hostname(tmp_path: Path) -> None:
- lock_path = tmp_path / "test.lock"
- lock = SoftFileLock(lock_path)
- with lock:
- content = lock_path.read_text(encoding="utf-8")
- lines = content.strip().splitlines()
- assert lines[0] == str(os.getpid())
- assert lines[1] == socket.gethostname()
- if sys.platform == "win32":
- assert len(lines) == 3
- int(lines[2]) # must be parseable as int (creation FILETIME)
- else:
- assert len(lines) == 2
[email protected]
+def lock_path(tmp_path: Path) -> Path:
+ return tmp_path / "test.lock"
-def test_stale_lock_broken_when_process_dead(tmp_path: Path, mocker:
MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
- dead_pid = 2**22 + 1
- lock_path.write_text(f"{dead_pid}\n{socket.gethostname()}\n",
encoding="utf-8")
- mocker.patch.object(SoftFileLock, "_is_process_alive", return_value=False)
+def _holder(pid: int, *, host: str = HOST, creation_time: int | None = None)
-> str:
+ lines = [str(pid), host, *([] if creation_time is None else
[str(creation_time)])]
+ return "\n".join(lines) + "\n"
+
+def _assert_self_heals(lock_path: Path) -> None:
lock = SoftFileLock(lock_path, timeout=1)
with lock:
assert lock.is_locked
-def test_stale_lock_not_broken_when_process_alive(tmp_path: Path, mocker:
MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
- lock_path.write_text(f"{os.getpid()}\n{socket.gethostname()}\n",
encoding="utf-8")
-
- mocker.patch.object(SoftFileLock, "_is_process_alive", return_value=True)
-
+def _assert_times_out(lock_path: Path) -> None:
lock = SoftFileLock(lock_path, timeout=0.1)
with pytest.raises(TimeoutError):
lock.acquire()
-def test_stale_lock_not_broken_different_hostname(tmp_path: Path) -> None:
- lock_path = tmp_path / "test.lock"
- dead_pid = 2**22 + 1
- lock_path.write_text(f"{dead_pid}\nother-host.example.com\n",
encoding="utf-8")
-
- lock = SoftFileLock(lock_path, timeout=0.1)
- with pytest.raises(TimeoutError):
- lock.acquire()
-
-
-@unix_only
-def test_stale_lock_not_broken_when_eperm(tmp_path: Path, mocker:
MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
- lock_path.write_text(f"{99999}\n{socket.gethostname()}\n",
encoding="utf-8")
+def test_lock_writes_pid_and_hostname(lock_path: Path) -> None:
+ lock = SoftFileLock(lock_path)
+ with lock:
+ lines = lock_path.read_text(encoding="utf-8").strip().splitlines()
+ assert lines[0] == str(os.getpid())
+ assert lines[1] == HOST
+ if sys.platform == "win32":
+ assert len(lines) == 3
+ int(lines[2]) # must be parseable as int (creation FILETIME)
+ else:
+ assert len(lines) == 2
- mocker.patch("filelock._soft.os.kill", side_effect=OSError(EPERM,
"Operation not permitted"))
- lock = SoftFileLock(lock_path, timeout=0.1)
- with pytest.raises(TimeoutError):
- lock.acquire()
[email protected](
+ "content",
+ [
+ pytest.param(_holder(DEAD_PID), id="two_line"),
+ pytest.param(_holder(DEAD_PID, creation_time=123456789),
id="three_line"),
+ ],
+)
+def test_stale_lock_broken_when_process_dead(lock_path: Path, mocker:
MockerFixture, content: str) -> None:
+ lock_path.write_text(content, encoding="utf-8")
+ mocker.patch.object(SoftFileLock, "_is_process_alive", return_value=False)
+ _assert_self_heals(lock_path)
-def test_stale_lock_malformed_evicted_when_old(tmp_path: Path) -> None:
- lock_path = tmp_path / "test.lock"
- lock_path.write_text("not-a-pid\n", encoding="utf-8")
- os.utime(lock_path, (0, 0))
+def test_stale_lock_not_broken_when_process_alive(lock_path: Path, mocker:
MockerFixture) -> None:
+ lock_path.write_text(_holder(os.getpid()), encoding="utf-8")
+ mocker.patch.object(SoftFileLock, "_is_process_alive", return_value=True)
+ _assert_times_out(lock_path)
- lock = SoftFileLock(lock_path, timeout=1)
- with lock:
- assert lock.is_locked
+def test_stale_lock_not_broken_different_hostname(lock_path: Path) -> None:
+ lock_path.write_text(_holder(DEAD_PID, host="other-host.example.com"),
encoding="utf-8")
+ _assert_times_out(lock_path)
-def test_stale_lock_malformed_not_evicted_when_fresh(tmp_path: Path) -> None:
- lock_path = tmp_path / "test.lock"
- lock_path.write_text("not-a-pid\n", encoding="utf-8")
- lock = SoftFileLock(lock_path, timeout=0.1)
- with pytest.raises(TimeoutError):
- lock.acquire()
+@unix_only
[email protected](
+ "errno",
+ [pytest.param(EPERM, id="eperm"), pytest.param(ENODEV,
id="unexpected_device")],
+)
+def test_stale_lock_not_broken_on_kill_error(lock_path: Path, mocker:
MockerFixture, errno: int) -> None:
+ lock_path.write_text(_holder(99999), encoding="utf-8")
+ mocker.patch("filelock._soft.os.kill", side_effect=OSError(errno, "kill
failed"))
+ _assert_times_out(lock_path)
-def test_stale_lock_empty_file_evicted_when_old(tmp_path: Path) -> None:
- lock_path = tmp_path / "test.lock"
- lock_path.write_text("", encoding="utf-8")
[email protected](
+ "content",
+ [
+ pytest.param(b"not-a-pid\n", id="malformed"),
+ pytest.param(b"", id="empty"),
+ pytest.param(b"x" * (_MAX_LOCK_FILE_SIZE + 1), id="oversized"),
+ pytest.param(b"not-a-pid\nhostname\n", id="two_line_bad_pid"),
+ pytest.param(f"{DEAD_PID}\nhostname\nnot-a-time\n".encode(),
id="three_line_bad_creation_time"),
+ ],
+)
+def test_unparseable_lock_evicted_when_old(lock_path: Path, content: bytes) ->
None:
+ lock_path.write_bytes(content)
os.utime(lock_path, (0, 0))
+ # An unreadable lock (wrong line count, non-integer pid/creation time,
empty, or oversized) must self-heal
+ # rather than stay stuck forever; line count alone is not enough to call a
file well-formed.
+ _assert_self_heals(lock_path)
- lock = SoftFileLock(lock_path, timeout=1)
- with lock:
- assert lock.is_locked
[email protected](
+ "content",
+ [
+ pytest.param(b"not-a-pid\n", id="malformed"),
+ pytest.param(b"", id="empty"),
+ pytest.param(b"not-a-pid\nhostname\n", id="two_line_bad_pid"),
+ ],
+)
+def test_unparseable_lock_not_evicted_when_fresh(lock_path: Path, content:
bytes) -> None:
+ lock_path.write_bytes(content)
+ _assert_times_out(lock_path)
-def test_stale_lock_empty_file_not_evicted_when_fresh(tmp_path: Path) -> None:
- lock_path = tmp_path / "test.lock"
- lock_path.write_text("", encoding="utf-8")
- lock = SoftFileLock(lock_path, timeout=0.1)
- with pytest.raises(TimeoutError):
- lock.acquire()
[email protected](
+ "pid",
+ [
+ pytest.param(0, id="zero"),
+ pytest.param(-1, id="negative"),
+ pytest.param(2**31, id="oversized"),
+ ],
+)
+def test_out_of_range_pid_self_heals_when_old(lock_path: Path, pid: int) ->
None:
+ lock_path.write_text(_holder(pid), encoding="utf-8")
+ os.utime(lock_path, (0, 0))
+ # A pid of 0 or -1 makes os.kill() probe the caller's own process group
(read as alive) so the lock is
+ # never reclaimed, and an oversized pid raises OverflowError out of stale
detection. Such a holder is
+ # malformed and must self-heal like any other unparsable one, matching
what _parse_marker_bytes rejects.
+ _assert_self_heals(lock_path)
+
+def test_out_of_range_pid_not_evicted_when_fresh(lock_path: Path) -> None:
+ lock_path.write_text(_holder(0), encoding="utf-8")
+ # A fresh out-of-range pid is malformed too, but like any malformed lock
it is left alone until it ages
+ # past the threshold, so a peer mid-write is never mistaken for a stale
lock.
+ _assert_times_out(lock_path)
-def test_stale_lock_rename_race(tmp_path: Path, mocker: MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
- dead_pid = 2**22 + 1
- lock_path.write_text(f"{dead_pid}\n{socket.gethostname()}\n",
encoding="utf-8")
+def test_stale_lock_rename_race(lock_path: Path, mocker: MockerFixture) ->
None:
+ lock_path.write_text(_holder(DEAD_PID), encoding="utf-8")
mocker.patch.object(SoftFileLock, "_is_process_alive", return_value=False)
mocker.patch.object(Path, "rename", side_effect=FileNotFoundError("already
gone"))
-
- lock = SoftFileLock(lock_path, timeout=0.1)
- with pytest.raises(TimeoutError):
- lock.acquire()
+ _assert_times_out(lock_path)
@unix_only
-def test_stale_lock_unexpected_kill_error_suppressed(tmp_path: Path, mocker:
MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
- lock_path.write_text(f"{99999}\n{socket.gethostname()}\n",
encoding="utf-8")
-
- mocker.patch("filelock._soft.os.kill", side_effect=OSError(ENODEV, "No
such device"))
-
- lock = SoftFileLock(lock_path, timeout=0.1)
- with pytest.raises(TimeoutError):
- lock.acquire()
-
-
-def test_stale_detection_errors_suppressed(tmp_path: Path, mocker:
MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
- lock_path.write_text(f"{os.getpid()}\n{socket.gethostname()}\n",
encoding="utf-8")
-
- mock_read: MagicMock = mocker.patch.object(Path, "read_text",
side_effect=OSError("read failed"))
-
- lock = SoftFileLock(lock_path, timeout=0.1)
- with pytest.raises(TimeoutError):
- lock.acquire()
+def test_symlinked_lock_file_is_not_followed(tmp_path: Path, lock_path: Path)
-> None:
+ target = tmp_path / "target"
+ target.write_text(_holder(99999), encoding="utf-8")
+ lock_path.symlink_to(target)
+
+ # Neither the pid read nor stale detection may follow the symlink onto the
target file.
+ assert SoftFileLock(lock_path).pid is None
+ assert target.read_text(encoding="utf-8") == _holder(99999)
+
+
+def test_fifo_lock_file_does_not_block(lock_path: Path) -> None:
+ if sys.platform == "win32":
+ pytest.skip("os.mkfifo is unix-only")
+ # An attacker-placed FIFO must not stall the open; O_NONBLOCK makes the
read bail instead of hang.
+ os.mkfifo(lock_path)
+ assert SoftFileLock(lock_path).pid is None
+
+
+def test_stale_detection_errors_suppressed(lock_path: Path, mocker:
MockerFixture) -> None:
+ lock_path.write_text(_holder(os.getpid()), encoding="utf-8")
+ mock_read: MagicMock = mocker.patch("filelock._soft._read_lock_file",
side_effect=OSError("read failed"))
+ _assert_times_out(lock_path)
mock_read.assert_called()
-def test_stale_lock_three_line_format_accepted(tmp_path: Path, mocker:
MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
- dead_pid = 2**22 + 1
- lock_path.write_text(f"{dead_pid}\n{socket.gethostname()}\n123456789\n",
encoding="utf-8")
-
- mocker.patch.object(SoftFileLock, "_is_process_alive", return_value=False)
-
- lock = SoftFileLock(lock_path, timeout=1)
- with lock:
- assert lock.is_locked
-
-
@win_only
-def test_windows_stale_lock_broken_when_pid_recycled(tmp_path: Path, mocker:
MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
- recycled_pid = 1234
- original_creation_time = 100000000
- new_creation_time = 999999999
-
- lock_path.write_text(
- f"{recycled_pid}\n{socket.gethostname()}\n{original_creation_time}\n",
- encoding="utf-8",
- )
-
+def test_windows_stale_lock_broken_when_pid_recycled(lock_path: Path, mocker:
MockerFixture) -> None:
+ lock_path.write_text(_holder(1234, creation_time=100000000),
encoding="utf-8")
mocker.patch.object(SoftFileLock, "_is_process_alive", return_value=True)
- mocker.patch.object(SoftFileLock, "_get_process_creation_time",
return_value=new_creation_time)
-
- lock = SoftFileLock(lock_path, timeout=1)
- with lock:
- assert lock.is_locked
+ mocker.patch.object(SoftFileLock, "_get_process_creation_time",
return_value=999999999)
+ _assert_self_heals(lock_path)
@win_only
-def test_windows_stale_lock_not_broken_same_creation_time(tmp_path: Path,
mocker: MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
- alive_pid = 1234
+def test_windows_stale_lock_not_broken_same_creation_time(lock_path: Path,
mocker: MockerFixture) -> None:
creation_time = 100000000
-
- lock_path.write_text(
- f"{alive_pid}\n{socket.gethostname()}\n{creation_time}\n",
- encoding="utf-8",
- )
-
+ lock_path.write_text(_holder(1234, creation_time=creation_time),
encoding="utf-8")
mocker.patch.object(SoftFileLock, "_is_process_alive", return_value=True)
mocker.patch.object(SoftFileLock, "_get_process_creation_time",
return_value=creation_time)
-
- lock = SoftFileLock(lock_path, timeout=0.1)
- with pytest.raises(TimeoutError):
- lock.acquire()
+ _assert_times_out(lock_path)
@win_only
-def test_windows_stale_lock_conservative_without_creation_time(tmp_path: Path,
mocker: MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
- alive_pid = 1234
- lock_path.write_text(f"{alive_pid}\n{socket.gethostname()}\n",
encoding="utf-8")
-
+def test_windows_stale_lock_conservative_without_creation_time(lock_path:
Path, mocker: MockerFixture) -> None:
+ lock_path.write_text(_holder(1234), encoding="utf-8")
mocker.patch.object(SoftFileLock, "_is_process_alive", return_value=True)
-
- lock = SoftFileLock(lock_path, timeout=0.1)
- with pytest.raises(TimeoutError):
- lock.acquire()
+ _assert_times_out(lock_path)
@win_only
@@ -229,8 +216,7 @@
@win_only
def test_get_process_creation_time_returns_none_for_dead_pid() -> None:
- result = SoftFileLock._get_process_creation_time(2**22 + 1)
- assert result is None
+ assert SoftFileLock._get_process_creation_time(DEAD_PID) is None
@pytest.mark.skipif(sys.platform == "win32", reason="unix-only")
@@ -242,19 +228,21 @@
("content", "expected"),
[
pytest.param(None, None, id="no_file"),
- pytest.param("not-a-number\n", None, id="malformed"),
- pytest.param(f"{os.getpid()}\n{socket.gethostname()}\n", os.getpid(),
id="valid"),
+ pytest.param(b"not-a-number\n", None, id="malformed"),
+ pytest.param(b"\xff\xfe\n", None, id="non_utf8"),
+ pytest.param(b"x" * (_MAX_LOCK_FILE_SIZE + 1), None, id="oversized"),
+ pytest.param(b"42\n", None, id="single_line"),
+ pytest.param(_holder(0).encode(), None, id="out_of_range_pid"),
+ pytest.param(_holder(os.getpid()).encode(), os.getpid(), id="valid"),
],
)
-def test_pid(tmp_path: Path, content: str | None, expected: int | None) ->
None:
- lock_path = tmp_path / "test.lock"
+def test_pid(lock_path: Path, content: bytes | None, expected: int | None) ->
None:
if content is not None:
- lock_path.write_text(content, encoding="utf-8")
+ lock_path.write_bytes(content)
assert SoftFileLock(lock_path).pid == expected
-def test_pid_while_locked(tmp_path: Path) -> None:
- lock_path = tmp_path / "test.lock"
+def test_pid_while_locked(lock_path: Path) -> None:
lock = SoftFileLock(lock_path)
with lock:
assert lock.pid == os.getpid()
@@ -264,12 +252,12 @@
("content", "expected"),
[
pytest.param(None, False, id="no_file"),
- pytest.param(f"{os.getpid() + 1}\n{socket.gethostname()}\n", False,
id="different_pid"),
- pytest.param(f"{os.getpid()}\n{socket.gethostname()}\n", True,
id="same_pid"),
+ pytest.param(_holder(os.getpid() + 1), False, id="different_pid"),
+ pytest.param(_holder(os.getpid()), True, id="same_pid"),
+ pytest.param(_holder(os.getpid(), host="other-host"), False,
id="same_pid_different_host"),
],
)
-def test_is_lock_held_by_us(tmp_path: Path, content: str | None, expected:
bool) -> None:
- lock_path = tmp_path / "test.lock"
+def test_is_lock_held_by_us(lock_path: Path, content: str | None, expected:
bool) -> None:
if content is not None:
lock_path.write_text(content, encoding="utf-8")
assert SoftFileLock(lock_path).is_lock_held_by_us is expected
@@ -279,16 +267,14 @@
"exists",
[pytest.param(True, id="exists"), pytest.param(False, id="missing")],
)
-def test_break_lock(tmp_path: Path, *, exists: bool) -> None:
- lock_path = tmp_path / "test.lock"
+def test_break_lock(lock_path: Path, *, exists: bool) -> None:
if exists:
- lock_path.write_text(f"{os.getpid()}\n{socket.gethostname()}\n",
encoding="utf-8")
+ lock_path.write_text(_holder(os.getpid()), encoding="utf-8")
SoftFileLock(lock_path).break_lock()
assert not lock_path.exists()
-def test_write_lock_info_errors_suppressed(tmp_path: Path, mocker:
MockerFixture) -> None:
- lock_path = tmp_path / "test.lock"
+def test_write_lock_info_errors_suppressed(lock_path: Path, mocker:
MockerFixture) -> None:
mocker.patch("filelock._soft.os.write", side_effect=OSError("write
failed"))
lock = SoftFileLock(lock_path)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/filelock-3.29.0/tests/test_util.py
new/filelock-3.29.4/tests/test_util.py
--- old/filelock-3.29.0/tests/test_util.py 1970-01-01 01:00:00.000000000
+0100
+++ new/filelock-3.29.4/tests/test_util.py 2020-02-02 01:00:00.000000000
+0100
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import os
+from typing import TYPE_CHECKING
+
+import pytest
+
+from filelock._util import break_lock_file
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from pytest_mock import MockerFixture
+
+
+def test_break_lock_file_unlinks_unchanged_file(tmp_path: Path) -> None:
+ lock = tmp_path / "test.lock"
+ lock.write_text("stale", encoding="utf-8")
+ st = os.lstat(lock)
+ break_lock_file(str(lock), st.st_mtime, st.st_ino)
+ assert not lock.exists()
+ assert list(tmp_path.glob("test.lock.break.*")) == []
+
+
+def test_break_lock_file_preserves_file_when_mtime_advanced(tmp_path: Path) ->
None:
+ lock = tmp_path / "test.lock"
+ lock.write_text("live", encoding="utf-8")
+ # A mtime_before older than the file's real mtime models a peer recreating
the lock after our stale read: the
+ # live file is renamed aside but must not be unlinked, so the holder's
content survives instead of two holders.
+ break_lock_file(str(lock), mtime_before=0.0,
ino_before=os.lstat(lock).st_ino)
+ assert not lock.exists()
+ leftover = list(tmp_path.glob("test.lock.break.*"))
+ assert len(leftover) == 1
+ assert leftover[0].read_text(encoding="utf-8") == "live"
+
+
+def test_break_lock_file_preserves_file_when_inode_changed(tmp_path: Path) ->
None:
+ lock = tmp_path / "test.lock"
+ lock.write_text("stale", encoding="utf-8")
+ st = os.lstat(lock)
+ # Model a coarse-granularity filesystem (NFS, FAT) where a peer broke and
recreated the lock with a new inode
+ # but the same mtime second. Creating the replacement while the original
still exists guarantees a fresh inode.
+ other = tmp_path / "recreated"
+ other.write_text("live", encoding="utf-8")
+ os.utime(other, ns=(st.st_atime_ns, st.st_mtime_ns))
+ assert os.lstat(other).st_ino != st.st_ino
+ other.replace(lock)
+ break_lock_file(str(lock), st.st_mtime, st.st_ino)
+ leftover = list(tmp_path.glob("test.lock.break.*"))
+ assert len(leftover) == 1
+ assert leftover[0].read_text(encoding="utf-8") == "live"
+
+
+def test_break_lock_file_aborts_if_break_path_vanishes(tmp_path: Path, mocker:
MockerFixture) -> None:
+ lock = tmp_path / "test.lock"
+ lock.write_text("x", encoding="utf-8")
+ ino = os.lstat(lock).st_ino
+ mocker.patch("filelock._util.os.lstat", side_effect=FileNotFoundError)
+ break_lock_file(str(lock), 0.0, ino)
+ assert not lock.exists()
+ assert len(list(tmp_path.glob("test.lock.break.*"))) == 1
+
+
+def test_break_lock_file_missing_source_raises(tmp_path: Path) -> None:
+ with pytest.raises(FileNotFoundError):
+ break_lock_file(str(tmp_path / "nope.lock"), 0.0, 0)