diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p1.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p1.patch
new file mode 100644
index 0000000000..d7a4bd5944
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p1.patch
@@ -0,0 +1,945 @@
+From 740ff666b62a88496c8d7394e4e6230605e4d62b Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= <gaborjbernat@gmail.com>
+Date: Sat, 17 Oct 2026 02:53:44 -0700
+Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(soft):=20break=20stale=20mar?=
+ =?UTF-8?q?kers=20without=20emptying=20the=20lock=20path=20(#843)?=
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+CVE: CVE-2026-112935
+Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b]
+
+Backport Changes:
+- Port inode pinning, atomic exchanges and stranded-marker recovery
+  to SoftFileLock; retain two-line PID/hostname owner records.
+- Use existing lock_file/context fields in place of 4.x descriptor
+  state, paths and identity helpers; keep the existing public API.
+- Override lifetime expiry through the secure takeover path and
+  adapt the existing tests for secure marker reads and write rollback.
+- Inject expiry-race failures through the atomic-exchange helpers
+  in the existing regression instead of the former Path.rename path.
+- Omit new marker/lease/strict-lock APIs and their version-specific
+  tests and docs; retain focused SoftFileLock regressions.
+
+Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ src/filelock/_soft.py       | 362 ++++++++++++++++++++++++++++++++++++++------
+ src/filelock/_util.py       | 267 +++++++++++++++++++++++++++++++-
+ tests/test_lock_expiry.py   |   3 +-
+ tests/test_soft_security.py | 129 ++++++++++++++++
+ tests/test_soft_stale.py    |  13 +-
+ 5 files changed, 719 insertions(+), 55 deletions(-)
+ create mode 100644 tests/test_soft_security.py
+
+diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py
+index 3a535ea..852ac71 100644
+--- a/src/filelock/_soft.py
++++ b/src/filelock/_soft.py
+@@ -1,9 +1,12 @@
+ from __future__ import annotations
+ 
+ import os
++import re
++import secrets
+ import socket
+ import stat
+ import sys
++import threading
+ import time
+ from contextlib import suppress
+ from errno import EACCES, EEXIST, EPERM, ESRCH
+@@ -11,68 +14,240 @@ from pathlib import Path
+ from typing import Final
+ 
+ from ._api import BaseFileLock
+-from ._util import ensure_directory_exists, raise_on_not_writable_file
+-
+-#: Contenders act on the record in the marker, so no other account may write it, whatever the mode or umask.
+-_OTHERS_WRITE: Final[int] = stat.S_IWGRP | stat.S_IWOTH
++from ._util import (
++    break_lock_file,
++    ensure_directory_exists,
++    exchange_names,
++    exchange_supported,
++    pin_inode,
++    raise_on_not_writable_file,
++    remove_own_file,
++    write_all,
++)
+ 
+ _WIN_SYNCHRONIZE = 0x100000
+ _WIN_ERROR_INVALID_PARAMETER = 87
++_MAX_LOCK_FILE_SIZE: Final[int] = 1024
++#: Contenders act on the record in the marker, so no other account may write it, whatever the mode or umask.
++_OTHERS_WRITE: Final[int] = stat.S_IWGRP | stat.S_IWOTH
++_CLAIMS_IN_PROGRESS: Final[set[str]] = set()
++_CLAIMS_LOCK: Final[threading.Lock] = threading.Lock()
++_RELEASED_RECORD: Final[str] = "filelock/released\n"
+ 
+ 
+ class SoftFileLock(BaseFileLock):
+     """
+     Portable file lock based on file existence.
+ 
+-    Unlike :class:`UnixFileLock <filelock.UnixFileLock>` and :class:`WindowsFileLock <filelock.WindowsFileLock>`, this
+-    lock does not use OS-level locking primitives. Instead, it creates the lock file with ``O_CREAT | O_EXCL`` and
+-    treats its existence as the lock indicator. This makes it work on any filesystem but leaves stale lock files behind
+-    if the process crashes without releasing the lock.
+-
+-    To mitigate stale locks, the lock file contains the PID and hostname of the holding process. On contention, if the
+-    holder is on the same host and its PID no longer exists, the stale lock is broken automatically.
++    The two-line PID/hostname record remains compatible with filelock 3.25.
++    A stale marker is pinned and exchanged atomically so reclaiming it cannot
++    remove a successor's live lock. Filesystems without an atomic exchange
++    keep the marker rather than leave the lock path temporarily empty.
+ 
+     """
+ 
+     def _acquire(self) -> None:
+         raise_on_not_writable_file(self.lock_file)
+         ensure_directory_exists(self.lock_file)
+-        flags = (
+-            os.O_WRONLY  # open for writing only
+-            | os.O_CREAT
+-            | os.O_EXCL  # together with above raise EEXIST if the file specified by filename exists
+-            | os.O_TRUNC  # truncate the file to zero byte
+-        )
++        flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC
+         if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None:
+             flags |= o_nofollow
+         try:
+-            file_handler = os.open(self.lock_file, flags, self._open_mode() & ~_OTHERS_WRITE)
++            fd = os.open(self.lock_file, flags, self._open_mode() & ~_OTHERS_WRITE)
+         except OSError as exception:
+-            if not (
+-                exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32")
+-            ):  # pragma: win32 no cover
++            if not (exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32")):
+                 raise
+-            if exception.errno == EEXIST and sys.platform != "win32":  # pragma: win32 no cover
++            if exception.errno == EEXIST and sys.platform != "win32":
+                 self._try_break_stale_lock()
+         else:
+-            self._write_lock_info(file_handler)
+-            self._context.lock_file_fd = file_handler
++            # Publish held state only once the record is on disk. On failure,
++            # close the descriptor and remove only the marker it created.
++            try:
++                self._write_lock_info(fd)
++            except BaseException:
++                try:
++                    with suppress(OSError):
++                        remove_own_file(self.lock_file, fd)
++                finally:
++                    os.close(fd)
++                raise
++            self._context.lock_file_fd = fd
+ 
+     def _try_break_stale_lock(self) -> None:
+-        with suppress(OSError, ValueError):
+-            content = Path(self.lock_file).read_text(encoding="utf-8")
+-            lines = content.strip().splitlines()
+-            if len(lines) != 2:  # noqa: PLR2004
++        with suppress(OSError, ValueError), pin_inode(self.lock_file) as pinned:
++            content, marker = _read_lock_file(self.lock_file)
++            if pinned not in {None, marker.st_ino}:
+                 return
+-            pid_str, hostname = lines
+-            if hostname != socket.gethostname():
++            if content == _RELEASED_RECORD:
++                self._take_over(marker, None)
+                 return
+-            pid = int(pid_str)
+-            if self._is_process_alive(pid):
++            holder = _parse_lock_holder(content)
++            if holder is None:
++                # 3.25 does not age out malformed or half-written markers.
+                 return
+-            break_path = f"{self.lock_file}.break.{os.getpid()}"
+-            Path(self.lock_file).rename(break_path)
+-            Path(break_path).unlink()
++            pid, hostname = holder
++            if hostname == socket.gethostname() and not self._is_process_alive(pid):
++                self._take_over(marker, pid)
++
++    def _try_break_expired_lock(self) -> None:
++        if (lifetime := self._context.lifetime) is None:
++            return
++        with suppress(OSError), pin_inode(self.lock_file) as pinned:
++            content, marker = _read_lock_file(self.lock_file)
++            if pinned not in {None, marker.st_ino} or time.time() - marker.st_mtime < lifetime:
++                return
++            holder = _parse_lock_holder(content)
++            self._take_over(marker, None if holder is None else holder[0])
++
++    def _take_over(self, marker: os.stat_result, breaker_pid: int | None) -> None:
++        # Swap the judged marker for a complete marker of ours in one exchange, so the lock path never stands empty.
++        # Once the file swapped out proves to be the one judged, this lock holds the path; the caller's pin keeps that
++        # file's inode number from naming anything else. Only a peer's exchange can move our marker after that, and
++        # the peer puts it back because what it took is not the file it judged. Every failure before the handoff takes
++        # our marker off the path and puts back what the exchange took. Without an exchange, the portable break decides.
++        if exchange_supported():  # pragma: win32 no cover
++            self._take_over_by_exchange(marker, breaker_pid)
++        else:
++            break_lock_file(self.lock_file, marker.st_mtime, marker.st_ino)
++
++
++    def _take_over_by_exchange(self, marker: os.stat_result, breaker_pid: int | None) -> None:  # pragma: win32 no cover
++        if (current := os.lstat(self.lock_file)).st_mtime != marker.st_mtime or current.st_ino != marker.st_ino:
++            return
++        if breaker_pid is not None and self._restore_stranded_marker(breaker_pid, marker):
++            return
++        aside: Final = f"{self.lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}"
++        flags: Final = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
++        fd: Final = os.open(aside, flags, self._open_mode() & ~_OTHERS_WRITE)
++        exchanged = held = False
++        try:
++            self._write_lock_info(fd)
++            if not exchange_names(aside, self.lock_file):
++                return  # NFS, SMB and the like refuse it; renaming the marker aside would empty the path
++            exchanged = True
++            if (taken := os.lstat(aside)).st_mtime != marker.st_mtime or taken.st_ino != marker.st_ino:
++                return  # a peer recreated the marker, which the cleanup below swaps back
++            Path(aside).unlink()
++            exchanged = False
++            self._context.lock_file_fd = fd
++            held = True
++        finally:
++            if not held:
++                self._abandon_take_over(fd, aside, put_back=exchanged)
++
++
++    def _restore_stranded_marker(self, breaker_pid: int, marker: os.stat_result) -> bool:  # pragma: win32 no cover
++        # A breaker killed between its exchange and the put-back leaves its own marker at the path and the live
++        # holder's marker under its break name. A recoverer first renames that record to a name of its own. Only one
++        # contender wins the rename, so two of them never exchange the same record and reverse each other. A record
++        # a dead recoverer holds is claimable again. A claim of this process that no thread is working on was
++        # abandoned by a failed put-back, and the next poll takes it up again. Where the marker cannot be put back
++        # the lock counts as held, as it does wherever no exchange is possible.
++        if (candidates := self._stranded_candidates(breaker_pid)) is None:
++            return True
++        return any(self._recover_claimed(path, breaker_pid, marker, adopt=abandoned) for path, abandoned in candidates)
++
++
++    def _stranded_candidates(self, breaker_pid: int) -> list[tuple[str, bool]] | None:  # pragma: win32 no cover
++        directory, name = os.path.split(self.lock_file)
++        stranded: Final = re.compile(rf"{re.escape(name)}\.break\.{breaker_pid}\.[0-9a-f]{{32}}")
++        claimed: Final = re.compile(rf"{re.escape(name)}\.restore\.(\d+)\.[0-9a-f]{{32}}")
++        candidates: list[tuple[str, bool]] = []
++        with os.scandir(directory or os.curdir) as entries:
++            for entry in entries:
++                if not entry.is_file(follow_symlinks=False):
++                    continue
++                path = str(Path(directory) / entry.name)
++                if stranded.fullmatch(entry.name):
++                    candidates.append((path, False))
++                elif found := claimed.fullmatch(entry.name):
++                    ours = int(found[1]) == os.getpid()
++                    if not (ours or not self._is_process_alive(int(found[1]))):
++                        return None
++                    candidates.append((path, ours))
++        return sorted(candidates)
++
++
++    def _recover_claimed(
++        self, path: str, breaker_pid: int, marker: os.stat_result, *, adopt: bool
++    ) -> bool:  # pragma: win32 no cover
++        # One try/finally owns the registration from the first step to the last, so no interruption can leave it set.
++        # Registered before the rename publishes the name, so a thread that scans meanwhile sees a claim in progress.
++        own: Final = path if adopt else f"{self.lock_file}.restore.{os.getpid()}.{secrets.token_hex(16)}"
++        if not _begin_claim(own):
++            return True
++        try:
++            if not adopt:
++                try:
++                    Path(path).rename(own)
++                except OSError:  # a peer that won the rename now holds the record, so the lock counts as held
++                    return True
++            return self._restore_claimed(own, breaker_pid, marker)
++        finally:
++            _end_claim(own)
++
++
++    def _restore_claimed(self, own: str, breaker_pid: int, marker: os.stat_result) -> bool:  # pragma: win32 no cover
++        try:
++            content, _ = _read_lock_file(own)
++        except OSError:
++            self._return_claim(own, breaker_pid)
++            return True
++        if not self._marker_is_live(content, breaker_pid):
++            with suppress(OSError):
++                Path(own).unlink()
++            return False
++        # The inode of the marker judged dead must still be at the path, or a peer has acted since.
++        with suppress(OSError):
++            current = os.lstat(self.lock_file)
++            if (current.st_ino, current.st_mtime) == (marker.st_ino, marker.st_mtime) and exchange_names(
++                own, self.lock_file
++            ):
++                if os.lstat(own).st_ino == marker.st_ino:
++                    Path(own).unlink()
++                    return True
++                # A peer replaced the path after the check, so the exchange took its marker: swap it straight back.
++                exchange_names(own, self.lock_file)
++        self._return_claim(own, breaker_pid)
++        return True
++
++
++    def _return_claim(self, own: str, breaker_pid: int) -> None:  # pragma: win32 no cover
++        # A refused rename leaves the claim under this process's PID, and the next poll retries it.
++        with suppress(OSError):
++            Path(own).rename(f"{self.lock_file}.break.{breaker_pid}.{secrets.token_hex(16)}")
++
++
++    def _abandon_take_over(self, fd: int, aside: str, *, put_back: bool) -> None:  # pragma: win32 no cover
++        self._context.lock_file_fd = None
++        try:
++            if put_back:
++                exchange_names(aside, self.lock_file)
++        finally:
++            try:
++                # Whichever name still holds our marker loses it: the descriptor keeps its inode number ours.
++                own: Final = os.fstat(fd)
++                for name in (self.lock_file, aside):
++                    with suppress(FileNotFoundError):
++                        if os.path.samestat(os.lstat(name), own):
++                            Path(name).unlink()
++            finally:
++                os.close(fd)
++
++
++    @classmethod
++    def _marker_is_live(cls, content: str | None, breaker_pid: int) -> bool:
++        if content == _RELEASED_RECORD:
++            return False
++        holder = _parse_lock_holder(content)
++        if holder is None:
++            # Empty, malformed and newer protocols remain held as in 3.25.
++            return True
++        pid, hostname = holder
++        if pid == breaker_pid and hostname == socket.gethostname():
++            return False
++        return hostname != socket.gethostname() or cls._is_process_alive(pid)
++
+ 
+     @staticmethod
+     def _is_process_alive(pid: int) -> bool:
+@@ -95,27 +270,58 @@ class SoftFileLock(BaseFileLock):
+             raise
+         return True
+ 
++
+     @staticmethod
+     def _write_lock_info(fd: int) -> None:
+-        with suppress(OSError):
+-            os.write(fd, f"{os.getpid()}\n{socket.gethostname()}\n".encode())
++        write_all(fd, f"{os.getpid()}\n{socket.gethostname()}\n".encode())
+ 
+     def _release(self) -> None:
+-        assert self._context.lock_file_fd is not None  # noqa: S101
+-        os.close(self._context.lock_file_fd)
+-        self._context.lock_file_fd = None
++        fd = self._context.lock_file_fd
++        assert fd is not None  # noqa: S101
++        identity: tuple[int, int] | None = None
++        with suppress(OSError):
++            identity = _file_identity(os.fstat(fd))
+         if sys.platform == "win32":
+-            self._windows_unlink_with_retry()
+-        else:
+-            with suppress(OSError):
++            self._context.lock_file_fd = None
++            try:
++                os.close(fd)
++            finally:
++                if identity is not None:
++                    self._windows_unlink_with_retry(identity)
++            return
++        unlinked = self._unlink_open_marker(fd, identity)
++        self._context.lock_file_fd = None
++        try:
++            os.close(fd)
++        finally:
++            if not unlinked and identity is not None:
++                with suppress(OSError):
++                    if _file_identity(os.lstat(self.lock_file)) == identity:
++                        Path(self.lock_file).unlink()
++
++    def _unlink_open_marker(self, fd: int, identity: tuple[int, int] | None) -> bool:
++        if identity is None:
++            return True
++        try:
++            if _file_identity(os.lstat(self.lock_file)) == identity:
+                 Path(self.lock_file).unlink()
++                return True
++            _leave_released_record(fd)
++        except FileNotFoundError:
++            return True
++        except OSError:
++            return False
++        return True
++
+ 
+-    def _windows_unlink_with_retry(self) -> None:
++    def _windows_unlink_with_retry(self, identity: tuple[int, int] | None = None) -> None:
+         max_retries = 10
+         retry_delay = 0.001
+         for attempt in range(max_retries):
+             # Windows doesn't immediately release file handles after close, causing EACCES/EPERM on unlink
+             try:
++                if identity is not None and _file_identity(os.lstat(self.lock_file)) != identity:
++                    return
+                 Path(self.lock_file).unlink()
+             except OSError as exc:  # noqa: PERF203
+                 if exc.errno not in {EACCES, EPERM}:
+@@ -127,6 +333,68 @@ class SoftFileLock(BaseFileLock):
+                 return
+ 
+ 
+-__all__ = [
+-    "SoftFileLock",
+-]
++def _file_identity(st: os.stat_result) -> tuple[int, int]:
++    # (st_dev, st_ino) names the concrete inode behind a path, so a marker recreated at the same pathname after an
++    # expired lease reads as a different file. CPython populates both on Windows from the volume serial and file index.
++    return st.st_dev, st.st_ino
++
++
++def _begin_claim(name: str | Path) -> bool:  # pragma: win32 no cover
++    # Keyed by the file name alone: its random token identifies the claim, and the spelling of the directory varies.
++    with _CLAIMS_LOCK:
++        if (key := Path(name).name) in _CLAIMS_IN_PROGRESS:
++            return False
++        _CLAIMS_IN_PROGRESS.add(key)
++        return True
++
++
++def _end_claim(name: str | Path) -> None:  # pragma: win32 no cover
++    with _CLAIMS_LOCK:
++        _CLAIMS_IN_PROGRESS.discard(Path(name).name)
++
++
++def _leave_released_record(fd: int) -> None:
++    # A peer's exchange holds our marker under its break name and will put it back. Overwriting the record lets
++    # contenders age it out instead of waiting on this live PID. The write comes before the truncate so the file is
++    # never empty.
++    if sys.platform != "win32":  # pragma: win32 no cover
++        os.pwrite(fd, released := b"filelock/released\n", 0)
++        os.ftruncate(fd, len(released))
++
++
++def _read_lock_file(path: str) -> tuple[str | None, os.stat_result]:
++    # A legitimate lock file is always a regular file. Classify the path with lstat first, so any other node (symlink,
++    # FIFO, socket, device) is reported as a malformed lock the caller can evict, without an os.open that would follow
++    # a symlink, stall on a FIFO, or fail on a socket and leave acquisition wedged. The stat result still flows back
++    # for the identity-checked stale break. lstat, not stat, so a hostile symlink is never followed onto its target.
++    st = os.lstat(path)
++    if not stat.S_ISREG(st.st_mode):  # pragma: needs fifo
++        return None, st
++    # Re-check on the opened handle: O_NOFOLLOW refuses a symlink swapped in after the lstat, O_NONBLOCK stops a FIFO
++    # swapped in from stalling the open, and the fstat catches any other non-regular replacement race before we read.
++    # The capped read stops a huge regular file (e.g. one filled from /dev/zero) from exhausting memory.
++    fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
++    try:
++        st = os.fstat(fd)
++        if not stat.S_ISREG(st.st_mode):  # pragma: no cover  # only a non-regular node swapped in after the lstat
++            return None, st
++        data = os.read(fd, _MAX_LOCK_FILE_SIZE + 1)
++    finally:
++        os.close(fd)
++    if len(data) <= _MAX_LOCK_FILE_SIZE:
++        with suppress(UnicodeDecodeError):
++            return data.decode("utf-8"), st
++    return None, st
++
++
++def _parse_lock_holder(content: str | None) -> tuple[int, str] | None:
++    if content is None or len(lines := content.strip().splitlines()) != 2:
++        return None
++    pid_text, hostname = lines
++    if not (pid_text.isascii() and pid_text.isdigit()):
++        return None
++    pid = int(pid_text)
++    return (pid, hostname) if 1 <= pid <= 2**31 - 1 else None
++
++
++__all__ = ["SoftFileLock"]
+diff --git a/src/filelock/_util.py b/src/filelock/_util.py
+index 6701523..98d4f6f 100644
+--- a/src/filelock/_util.py
++++ b/src/filelock/_util.py
+@@ -1,10 +1,20 @@
+ from __future__ import annotations
+ 
++import errno
+ import os
++import platform
++import secrets
+ import stat
+ import sys
+-from errno import EACCES, EISDIR
++from contextlib import contextmanager, suppress
++from errno import EACCES, EINVAL, EIO, EISDIR, ENOSYS, EXDEV
++from functools import cache
+ from pathlib import Path
++from typing import TYPE_CHECKING, Final, cast
++
++if TYPE_CHECKING:
++    import ctypes
++    from collections.abc import Callable, Generator
+ 
+ 
+ def raise_on_not_writable_file(filename: str) -> None:
+@@ -47,7 +57,262 @@ def ensure_directory_exists(filename: Path | str) -> None:
+     Path(filename).parent.mkdir(parents=True, exist_ok=True)
+ 
+ 
++def write_all(fd: int, data: bytes) -> None:
++    """
++    Write the whole buffer to *fd*, looping over the short writes POSIX lets ``os.write`` make.
++
++    A caller that stopped after one short write would leave a truncated marker behind. A peer that reads between two
++    writes sees a prefix of the record; each caller has to tolerate that. Lock records need not survive a power loss, so
++    we skip ``fsync``.
++
++    :param fd: file descriptor open for writing.
++    :param data: bytes to write in full.
++
++    :raises OSError: if a write reports zero progress before the record is complete.
++
++    """
++    remaining = memoryview(data)
++    while remaining:
++        if (written := os.write(fd, remaining)) == 0:
++            raise OSError(EIO, "os.write wrote 0 bytes before the record was complete")
++        remaining = remaining[written:]
++
++
++@contextmanager
++def pin_inode(lock_file: str) -> Generator[int | None, None, None]:
++    """
++    Keep the file at *lock_file* allocated for the length of the block and yield its inode number.
++
++    An inode is the kernel's identity for a file, independent of its name, and its number names a file only while the
++    inode stays allocated. ext4 hands a freed number to the next file created. Once a stale marker is unlinked, the next
++    holder's marker can therefore carry the number a contender read a moment ago. A caller that reads the marker inside
++    the block, gives up unless it read the yielded inode, and breaks the marker before leaving can rely on the number.
++
++    :param lock_file: path to the lock file about to be read and perhaps broken.
++
++    :returns: the pinned inode number, or ``None`` where nothing can be pinned. Windows cannot rename a file this
++        process holds open, so it needs no pin. Without ``O_PATH`` a symlink or a socket cannot be opened.
++
++    :raises FileNotFoundError: if the path is gone.
++
++    """
++    if sys.platform == "win32":  # pragma: win32 cover
++        yield None
++    else:  # pragma: win32 no cover
++        # O_PATH pins any node, a symlink or socket included, without read permission and without the filesystem seeing
++        # an open; elsewhere a nonblocking read-only open pins what it can.
++        flags: Final = getattr(os, "O_PATH", os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)) | getattr(os, "O_NOFOLLOW", 0)
++        try:
++            fd = os.open(lock_file, flags)
++        except FileNotFoundError:
++            raise
++        except OSError:
++            yield None
++            return
++        try:
++            yield os.fstat(fd).st_ino
++        finally:
++            os.close(fd)
++
++
++def remove_own_file(lock_file: str, fd: int) -> None:  # pragma: win32 no cover
++    """
++    Remove *lock_file* while it still names the file open as *fd*; the caller closes the descriptor afterwards.
++
++    Where the platform has an atomic exchange, a stale break takes the file through it, so a peer's marker found at the
++    path goes back. Otherwise the path is unlinked by name if it still names this file. The open descriptor keeps the
++    inode allocated, so a matching number cannot belong to a successor's marker.
++
++    :raises OSError: if the descriptor cannot be read or the unlink fails.
++
++    """
++    own: Final = os.fstat(fd)
++    with suppress(FileNotFoundError):
++        break_lock_file(lock_file, own.st_mtime, own.st_ino)
++        if os.path.samestat(os.lstat(lock_file), own):
++            Path(lock_file).unlink()
++
++
++def break_lock_file(lock_file: str, mtime_before: float, ino_before: int) -> None:
++    """
++    Remove the lock file a caller found stale with modification time *mtime_before* and inode *ino_before*.
++
++    The caller keeps the judged file open until we return, through :func:`pin_inode` or its own descriptor, so no
++    other file can carry *ino_before* meanwhile. Right before breaking we ``lstat`` the lock again and leave it alone
++    if a peer recreated it since the caller judged it stale, as Mercurial re-reads a lock under its break lock.
++
++    On Linux (``renameat2``) and macOS (``renamex_np``) the platform can exchange two names in one step. There we swap
++    the marker with a placeholder of our own, so the lock path never stands empty, and a contender that polls meanwhile
++    finds a file it cannot parse and waits. If the file we took is not the one judged stale, a peer recreated the lock,
++    and a second exchange puts it back. Otherwise we remove it and then our placeholder. Of several processes racing to
++    break the same lock, only one takes the stale file.
++
++    Windows cannot exchange names, but it refuses to rename a marker its live holder keeps open. There we rename the
++    marker aside and, if it was recreated after all, link it back. ``link`` never replaces a name, so a marker a third
++    process created meanwhile keeps the path. Elsewhere without an exchange (NFS, SMB and other filesystems that refuse
++    it, platforms without the call) a rename would leave the path empty beside a possibly live holder. We then do not
++    move the marker, and the caller waits for ``timeout`` or ``break_lock()``.
++
++    We compare inodes as well as modification times, because NFS and FAT store coarse modification times. The times are
++    compared for equality, since a file's owner can set any time. We ``lstat`` so a symlink a peer swaps in is never
++    followed. The break name carries a random token so no other process can guess it.
++
++    :param lock_file: path to the lock file to break.
++    :param mtime_before: modification time the caller saw when it judged the lock stale.
++    :param ino_before: inode number the caller saw when it judged the lock stale.
++
++    :raises OSError: if taking the marker fails, for example because the file vanished, another user owns it in a
++        sticky directory, or on Windows its holder keeps it open.
++
++    """
++    if (current := os.lstat(lock_file)).st_mtime != mtime_before or current.st_ino != ino_before:
++        return
++    break_path: Final[str] = f"{lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}"
++    if exchange_supported():  # pragma: win32 no cover
++        _swap_out(lock_file, break_path, mtime_before, ino_before)
++    elif _MOVES_WITHOUT_EXCHANGE:  # pragma: win32 cover
++        _break_by_rename(lock_file, break_path, mtime_before, ino_before)
++
++
++def _swap_out(lock_file: str, break_path: str, mtime_before: float, ino_before: int) -> None:  # pragma: win32 no cover
++    # An atomic exchange with a placeholder takes the marker without the lock path ever being empty. No contender can
++    # then create a marker while this one is judged, and a live marker goes back the same way.
++    fd: Final = os.open(break_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
++    try:
++        try:
++            # Not a record any lock writes, so a contender that reads it waits, and one left by a crash ages out. The
++            # PID tells the contender that evicts it whose break names to search for a marker this process left.
++            write_all(fd, f"filelock/break\n{os.getpid()}\n".encode())
++            exchanged = exchange_names(break_path, lock_file)
++        except OSError:
++            exchanged = False
++        if not exchanged:
++            Path(break_path).unlink()
++            return
++        if (grabbed := os.lstat(break_path)).st_mtime == mtime_before and grabbed.st_ino == ino_before:
++            Path(break_path).unlink()
++            # Our descriptor keeps the placeholder's inode allocated, so a matching number at the path is still ours.
++            if os.path.samestat(os.lstat(lock_file), os.fstat(fd)):
++                Path(lock_file).unlink()
++        else:
++            exchange_names(break_path, lock_file)
++            Path(break_path).unlink()
++    finally:
++        os.close(fd)
++
++
++def _break_by_rename(
++    lock_file: str, break_path: str, mtime_before: float, ino_before: int
++) -> None:  # pragma: win32 cover
++    # Renaming the marker aside empties the path until a recreated live marker is put back. link never replaces a name,
++    # so a marker a third process created meanwhile keeps the path, and the live one waits under the break name.
++    # Without os.link, or where the filesystem refuses hard links, the live one waits there too.
++    Path(lock_file).rename(break_path)
++    if (st_after := os.lstat(break_path)).st_mtime == mtime_before and st_after.st_ino == ino_before:
++        Path(break_path).unlink()
++    elif stat.S_ISREG(st_after.st_mode) and hasattr(os, "link"):  # pragma: needs hard-link
++        with suppress(OSError):
++            os.link(break_path, lock_file, follow_symlinks=False)
++            Path(break_path).unlink()
++
++
++def exchange_supported() -> bool:
++    """Whether this platform has a call that swaps two names in one step; a filesystem may still refuse it."""
++    return _rename_call() is not None
++
++
++def exchange_names(source: str, target: str) -> bool:  # pragma: win32 no cover
++    """
++    Swap the names *source* and *target* in one step, so neither ever stands empty, where :func:`exchange_supported`.
++
++    :returns: ``False`` where the filesystem cannot, so the caller falls back
++
++    :raises OSError: for any other failure, such as a missing name or a denied rename
++
++    """
++    renamer, exchange_flag = cast("tuple[Callable[[bytes, bytes, int], int], int]", _rename_call())
++    if not (error := renamer(os.fsencode(source), os.fsencode(target), exchange_flag)):
++        return True
++    # EXDEV: overlayfs and some FUSE filesystems refuse an exchange between layers.
++    if error in {EINVAL, ENOSYS, EXDEV, getattr(errno, "ENOTSUP", EINVAL), getattr(errno, "EOPNOTSUPP", EINVAL)}:
++        return False
++    raise OSError(error, os.strerror(error), source, None, target)
++
++
++@cache
++def _rename_call() -> tuple[Callable[[bytes, bytes, int], int], int] | None:
++    # The call returns 0 or the errno. Looked up on first use, since most processes never break a lock and ctypes is
++    # slow to import. None where ctypes is missing (GraalPy) or the platform has no exchange.
++    try:
++        import ctypes  # ruff:ignore[import-outside-top-level]  # only a stale break needs it
++    except ImportError:  # pragma: no cover  # GraalPy ships without ctypes
++        return None
++    if sys.platform in {"linux", "android"}:  # pragma: linux cover
++        if (renameat2 := _renameat2(ctypes.CDLL(None, use_errno=True))) is None:
++            return None  # pragma: no cover  # an architecture the CI matrix does not run
++        return renameat2, 2  # RENAME_EXCHANGE in linux/fs.h
++    if sys.platform == "darwin":  # pragma: darwin cover
++        renamex_np: Final = ctypes.CDLL(None, use_errno=True).renamex_np
++        renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
++        renamex_np.restype = ctypes.c_int
++
++        def call_renamex_np(source: bytes, target: bytes, flags: int) -> int:
++            return 0 if renamex_np(source, target, flags) == 0 else ctypes.get_errno()
++
++        return call_renamex_np, 0x2  # RENAME_SWAP in sys/stdio.h
++    return None  # pragma: linux no cover  # pragma: android no cover  # pragma: darwin no cover
++
++
++def _renameat2(libc: ctypes.CDLL) -> Callable[[bytes, bytes, int], int] | None:  # pragma: linux cover
++    # Reached through syscall(2) rather than the C library's wrapper. glibc 2.28, musl 1.2.2 and bionic API 30 added
++    # the wrapper, and Termux ships an older bionic; the kernel has had the call since 3.15. The number is the
++    # architecture's own, so an architecture outside the table gets no exchange rather than another call.
++    import ctypes  # ruff:ignore[import-outside-top-level]  # only a stale break needs it
++
++    numbers: Final = {
++        "x86_64": 316,
++        "i386": 353,
++        "i686": 353,
++        "armv7l": 382,
++        "armv8l": 382,
++        "aarch64": 276,
++        "riscv64": 276,
++        "loongarch64": 276,
++        "ppc64le": 357,
++        "s390x": 347,
++    }
++    if (number := numbers.get(platform.machine())) is None:
++        return None  # pragma: no cover  # an architecture the CI matrix does not run
++    syscall: Final = libc.syscall
++    syscall.restype = ctypes.c_long
++    at_fdcwd: Final = -100
++
++    def call_renameat2(source: bytes, target: bytes, flags: int) -> int:
++        arguments: Final = (
++            ctypes.c_long(number),
++            ctypes.c_int(at_fdcwd),
++            ctypes.c_char_p(source),
++            ctypes.c_int(at_fdcwd),
++            ctypes.c_char_p(target),
++            ctypes.c_uint(flags),
++        )
++        return 0 if syscall(*arguments) == 0 else ctypes.get_errno()
++
++    return call_renameat2
++
++
++# Windows refuses to rename a marker its live holder keeps open, so renaming aside only moves a marker nobody holds.
++# Elsewhere without an exchange a contender could take the emptied path beside a live holder, so the marker stays and
++# the lock counts as held.
++_MOVES_WITHOUT_EXCHANGE: Final[bool] = sys.platform == "win32"
++
+ __all__ = [
++    "break_lock_file",
++    "exchange_names",
++    "exchange_supported",
++    "pin_inode",
++    "remove_own_file",
++    "write_all",
+     "ensure_directory_exists",
+     "raise_on_not_writable_file",
+ ]
+diff --git a/tests/test_lock_expiry.py b/tests/test_lock_expiry.py
+index c4481ca..14042a7 100644
+--- a/tests/test_lock_expiry.py
++++ b/tests/test_lock_expiry.py
+@@ -49,7 +49,8 @@ def test_expired_lock_race_rename_fails(tmp_path: Path, mocker: MockerFixture) -
+     lock_path.touch()
+     os.utime(lock_path, (0, 0))
+ 
+-    mocker.patch("filelock._api.pathlib.Path.rename", side_effect=FileNotFoundError)
++    mocker.patch("filelock._soft.exchange_supported", return_value=True)
++    mocker.patch("filelock._soft.exchange_names", side_effect=FileNotFoundError)
+ 
+     lock = SoftFileLock(lock_path, lifetime=0.1, timeout=0.5)
+     with pytest.raises(TimeoutError):
+diff --git a/tests/test_soft_security.py b/tests/test_soft_security.py
+new file mode 100644
+index 0000000..2a49e1f
+--- /dev/null
++++ b/tests/test_soft_security.py
+@@ -0,0 +1,129 @@
++from __future__ import annotations
++
++import os
++import socket
++import sys
++import time
++from pathlib import Path
++from typing import TYPE_CHECKING, Final
++
++import pytest
++
++from filelock import SoftFileLock, Timeout
++from filelock._util import exchange_names, exchange_supported
++
++if TYPE_CHECKING:
++    from collections.abc import Callable
++    from pytest_mock import MockerFixture
++
++pytestmark = pytest.mark.skipif(sys.platform == "win32" or not exchange_supported(), reason="needs atomic exchange")
++_DEAD_PID = 2**22 + 1
++_HOST = socket.gethostname()
++
++@pytest.fixture
++def lock_path(tmp_path: Path) -> Path:
++    return tmp_path / "test.lock"
++
++@pytest.fixture
++def lock_type() -> type[SoftFileLock]:
++    return SoftFileLock
++
++@pytest.fixture
++def record() -> str:
++    return _holder(_DEAD_PID)
++
++@pytest.fixture
++def on_break_recheck(mocker: MockerFixture) -> Callable[[Path, Callable[[], None]], None]:
++    """Schedule an action for right after the stale break re-reads the lock file and before it takes the marker."""
++
++    def schedule(lock: Path, action: Callable[[], None]) -> None:
++        alive: Final = SoftFileLock._is_process_alive
++        lstat: Final = os.lstat
++        state: list[str] = []
++
++        def judged(pid: int) -> bool:
++            # The owner check comes right before the break, whose first look at the path is the recheck.
++            state.append("judged")
++            return alive(pid)
++
++        def lstat_then_act(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result:
++            found: Final = lstat(path, dir_fd=dir_fd)
++            if state == ["judged"] and os.fspath(path) == os.fspath(lock):
++                state.append("acted")
++                action()
++            return found
++
++        mocker.patch.object(SoftFileLock, "_is_process_alive", side_effect=judged)
++        mocker.patch.object(os, "lstat", new=lstat_then_act)
++
++    return schedule
++
++
++def _holder(pid: int, *, host: str = _HOST, start: int | None = None) -> str:
++    return "\n".join([str(pid), host, *([] if start is None else [str(start)])]) + "\n"
++
++
++def test_stale_break_racing_a_successor_admits_no_third_holder(
++    lock_path: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None]
++) -> None:
++    lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8")
++    successor: Final[SoftFileLock] = SoftFileLock(lock_path, timeout=0)
++
++    def successor_breaks_and_acquires_first() -> None:
++        lock_path.unlink()
++        successor.acquire()
++
++    on_break_recheck(lock_path, successor_breaks_and_acquires_first)
++    _assert_times_out(lock_path)
++    mocker.stopall()
++
++    _assert_times_out(lock_path)
++    successor.release()
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
++def test_stale_break_keeps_the_lock_when_a_peer_swaps_its_marker_aside(  # pragma: win32 no cover
++    lock_path: Path, mocker: MockerFixture
++) -> None:
++    lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8")
++    (peer := lock_path.with_name("peer")).write_text(_holder(os.getpid()), encoding="utf-8")
++    unlink: Final = Path.unlink
++
++    def peer_exchanges_then_unlink(path: Path, *, missing_ok: bool = False) -> None:
++        exchange_names(str(lock_path), str(peer))
++        unlink(path, missing_ok=missing_ok)
++
++    mocker.patch.object(Path, "unlink", new=peer_exchanges_then_unlink)
++    lock: Final = SoftFileLock(lock_path)
++    lock.acquire(blocking=False)
++    mocker.stopall()
++    exchange_names(str(lock_path), str(peer))
++
++    assert (lock.is_locked, lock_path.read_text(encoding="utf-8")) == (True, _holder(os.getpid()))
++    lock.release()
++    assert list(lock_path.parent.glob("test.lock*")) == []
++
++
++def _break_name(lock: Path, pid: int = _DEAD_PID) -> Path:  # pragma: win32 no cover
++    return lock.with_name(f"test.lock.break.{pid}.{'0' * 32}")
++
++
++def _live(record: str) -> str:  # pragma: win32 no cover
++    return record.replace(str(_DEAD_PID), str(os.getpid()))
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
++def test_stale_break_restores_a_live_marker_a_dead_breaker_left_under_its_break_name(  # pragma: win32 no cover
++    tmp_path: Path, lock_type: type[SoftFileLock], record: str
++) -> None:
++    (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8")
++    _break_name(lock).write_text(_live(record), encoding="utf-8")
++    with pytest.raises(Timeout):
++        lock_type(lock).acquire(blocking=False)
++
++    assert (lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*"))) == (_live(record), [])
++
++
++def _assert_times_out(lock_path: Path, *, timeout: float = 0.1) -> None:
++    with pytest.raises(TimeoutError):
++        SoftFileLock(lock_path, timeout=timeout).acquire()
+diff --git a/tests/test_soft_stale.py b/tests/test_soft_stale.py
+index ab1ef4f..fcd76e4 100644
+--- a/tests/test_soft_stale.py
++++ b/tests/test_soft_stale.py
+@@ -100,7 +100,7 @@ def test_stale_lock_rename_race(tmp_path: Path, mocker: MockerFixture) -> None:
+     lock_path.write_text(f"{dead_pid}\n{socket.gethostname()}\n", encoding="utf-8")
+ 
+     mocker.patch("filelock._soft.os.kill", side_effect=OSError(ESRCH, "No such process"))
+-    mocker.patch.object(Path, "rename", side_effect=FileNotFoundError("already gone"))
++    mocker.patch("filelock._soft.exchange_names", side_effect=FileNotFoundError("already gone"))
+ 
+     lock = SoftFileLock(lock_path, timeout=0.1)
+     with pytest.raises(TimeoutError):
+@@ -124,7 +124,7 @@ def test_stale_detection_errors_suppressed(tmp_path: Path, mocker: MockerFixture
+     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"))
++    mock_read: MagicMock = mocker.patch("filelock._soft._read_lock_file", side_effect=OSError("read failed"))
+ 
+     lock = SoftFileLock(lock_path, timeout=0.1)
+     with pytest.raises(TimeoutError):
+@@ -132,11 +132,12 @@ def test_stale_detection_errors_suppressed(tmp_path: Path, mocker: MockerFixture
+     mock_read.assert_called()
+ 
+ 
+-def test_write_lock_info_errors_suppressed(tmp_path: Path, mocker: MockerFixture) -> None:
++def test_write_lock_info_errors_roll_back(tmp_path: Path, mocker: MockerFixture) -> None:
+     lock_path = tmp_path / "test.lock"
+     mocker.patch("filelock._soft.os.write", side_effect=OSError("write failed"))
+ 
+     lock = SoftFileLock(lock_path)
+-    with lock:
+-        assert lock.is_locked
+-        assert not lock_path.read_text(encoding="utf-8")
++    with pytest.raises(OSError, match="write failed"):
++        lock.acquire()
++    assert not lock.is_locked
++    assert not lock_path.exists()
diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p2.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p2.patch
new file mode 100644
index 0000000000..6b9c14ebdc
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p2.patch
@@ -0,0 +1,90 @@
+From 5bfde2890ff907a5318c1b7f815fcd52a887f127 Mon Sep 17 00:00:00 2001
+From: Javid Khan <dxbjavid@gmail.com>
+Date: Wed, 28 Oct 2026 11:55:11 +0530
+Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(soft):=20ignore=20a=20restor?=
+ =?UTF-8?q?e=20claim=20with=20an=20invalid=20pid=20(#871)?=
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+CVE: CVE-2026-112935
+Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/5bfde2890ff907a5318c1b7f815fcd52a887f127]
+
+Backport Changes:
+- Apply recovery-claim PID validation to the older SoftFileLock and
+  its existing PID liveness probe.
+- Restrict the upstream regression to SoftFileLock and the existing
+  two-line owner record.
+- Omit the standalone changelog fragment, which is absent from the
+  3.25.2 source archive.
+
+Co-authored-by: Bernát Gábor <gaborjbernat@gmail.com>
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ src/filelock/_soft.py       | 10 ++++++----
+ tests/test_soft_security.py | 27 +++++++++++++++++++++++++++
+ 2 files changed, 33 insertions(+), 4 deletions(-)
+
+diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py
+index 852ac71..1681b79 100644
+--- a/src/filelock/_soft.py
++++ b/src/filelock/_soft.py
+@@ -151,7 +151,8 @@ class SoftFileLock(BaseFileLock):
+     def _stranded_candidates(self, breaker_pid: int) -> list[tuple[str, bool]] | None:  # pragma: win32 no cover
+         directory, name = os.path.split(self.lock_file)
+         stranded: Final = re.compile(rf"{re.escape(name)}\.break\.{breaker_pid}\.[0-9a-f]{{32}}")
+-        claimed: Final = re.compile(rf"{re.escape(name)}\.restore\.(\d+)\.[0-9a-f]{{32}}")
++        # [0-9], not \d: a str pattern's \d takes any Unicode digit, and int() reads fullwidth ones as another number.
++        claimed: Final = re.compile(rf"{re.escape(name)}\.restore\.([0-9]+)\.[0-9a-f]{{32}}")
+         candidates: list[tuple[str, bool]] = []
+         with os.scandir(directory or os.curdir) as entries:
+             for entry in entries:
+@@ -160,9 +161,10 @@ class SoftFileLock(BaseFileLock):
+                 path = str(Path(directory) / entry.name)
+                 if stranded.fullmatch(entry.name):
+                     candidates.append((path, False))
+-                elif found := claimed.fullmatch(entry.name):
+-                    ours = int(found[1]) == os.getpid()
+-                    if not (ours or not self._is_process_alive(int(found[1]))):
++                elif (found := claimed.fullmatch(entry.name)) and 1 <= (recoverer := int(found[1])) <= 2**31 - 1:
++                    # No filelock writes a claim outside the pid range; os.kill would raise OverflowError on one.
++                    ours = recoverer == os.getpid()
++                    if not (ours or not self._is_process_alive(recoverer)):
+                         return None
+                     candidates.append((path, ours))
+         return sorted(candidates)
+diff --git a/tests/test_soft_security.py b/tests/test_soft_security.py
+index 2a49e1f..3da9bd1 100644
+--- a/tests/test_soft_security.py
++++ b/tests/test_soft_security.py
+@@ -127,3 +127,30 @@ def test_stale_break_restores_a_live_marker_a_dead_breaker_left_under_its_break_
+ def _assert_times_out(lock_path: Path, *, timeout: float = 0.1) -> None:
+     with pytest.raises(TimeoutError):
+         SoftFileLock(lock_path, timeout=timeout).acquire()
++
++
++def _restore_name(lock: Path, pid: int | str) -> Path:  # pragma: win32 no cover
++    return lock.with_name(f"test.lock.restore.{pid}.{'0' * 32}")
++
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
++@pytest.mark.parametrize(
++    "recoverer",
++    [
++        pytest.param(str(2**64), id="oversized"),
++        pytest.param("0", id="zero"),
++        # This process's pid in fullwidth digits, which int() reads back as the same number.
++        pytest.param("".join(chr(0xFF10 + int(digit)) for digit in str(os.getpid())), id="wide"),
++    ],
++)
++def test_stale_break_ignores_a_claim_naming_no_valid_pid(  # pragma: win32 no cover
++    tmp_path: Path, lock_type: type[SoftFileLock], record: str, recoverer: str
++) -> None:
++    (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8")
++    foreign: Final = record.replace(_HOST, "elsewhere")
++    (planted := _restore_name(lock, recoverer)).write_text(foreign, encoding="utf-8")
++    with lock_type(lock) as held:
++        assert held.is_locked
++
++    assert planted.read_text(encoding="utf-8") == foreign
diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p3.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p3.patch
new file mode 100644
index 0000000000..ec5cf824a3
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p3.patch
@@ -0,0 +1,425 @@
+From 77eb01a21ce842659e7beb9fd50a3dc935647416 Mon Sep 17 00:00:00 2001
+From: Javid Khan <dxbjavid@gmail.com>
+Date: Sat, 31 Oct 2026 20:38:25 +0530
+Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(soft):=20restore=20raced=20s?=
+ =?UTF-8?q?tale=20markers=20(#899)?=
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+CVE: CVE-2026-112935
+Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/77eb01a21ce842659e7beb9fd50a3dc935647416]
+
+Backport Changes:
+- Adapt corrected exchanges and cleanup to the older descriptor
+  context; retain nested-breaker and refused-restore regressions.
+- Add the fixed ctime-sensitive observation only for private exchange
+  placeholders so interrupted helpers can restore stranded holders.
+- Preserve older behavior for empty and malformed user markers; omit
+  the newer general malformed-marker and lease APIs.
+- Guard the older acquire loop when lifetime takeover already owns
+  the descriptor; add a regression for explicit read-only modes.
+- Adapt upstream fixtures and imports for the older test harness.
+- Omit the standalone changelog and concepts documentation, which
+  are absent from the 3.25.2 source archive.
+
+Co-authored-by: Bernát Gábor <gaborjbernat@gmail.com>
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ src/filelock/_soft.py       |  57 ++++++++++++++-
+ src/filelock/_util.py       |  36 ++++++++-
+ tests/test_soft_security.py | 175 +++++++++++++++++++++++++++++++++++++++++++-
+ 3 files changed, 264 insertions(+), 4 deletions(-)
+
+diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py
+index 1681b79..dd076af 100644
+--- a/src/filelock/_soft.py
++++ b/src/filelock/_soft.py
+@@ -17,6 +17,7 @@ from ._api import BaseFileLock
+ from ._util import (
+     break_lock_file,
+     ensure_directory_exists,
++    exchange_back,
+     exchange_names,
+     exchange_supported,
+     pin_inode,
+@@ -27,6 +28,7 @@ from ._util import (
+ 
+ _WIN_SYNCHRONIZE = 0x100000
+ _WIN_ERROR_INVALID_PARAMETER = 87
++MALFORMED_LOCK_AGE_THRESHOLD: Final[float] = 2.0
+ _MAX_LOCK_FILE_SIZE: Final[int] = 1024
+ #: Contenders act on the record in the marker, so no other account may write it, whatever the mode or umask.
+ _OTHERS_WRITE: Final[int] = stat.S_IWGRP | stat.S_IWOTH
+@@ -46,7 +48,12 @@ class SoftFileLock(BaseFileLock):
+ 
+     """
+ 
++    _marker_seen: tuple[tuple[float, int, int], float] | None = None
++
+     def _acquire(self) -> None:
++        # The 3.25 acquire loop calls us even after the expiry hook claimed the marker.
++        if self.is_locked:
++            return
+         raise_on_not_writable_file(self.lock_file)
+         ensure_directory_exists(self.lock_file)
+         flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC
+@@ -81,6 +88,11 @@ class SoftFileLock(BaseFileLock):
+             if content == _RELEASED_RECORD:
+                 self._take_over(marker, None)
+                 return
++            if (breaker := _placeholder_breaker(content)) is not None:
++                # Only our private exchange placeholder is aged out; malformed legacy markers stay held.
++                if self._marker_unchanged_for(marker) >= MALFORMED_LOCK_AGE_THRESHOLD:
++                    self._take_over(marker, breaker)
++                return
+             holder = _parse_lock_holder(content)
+             if holder is None:
+                 # 3.25 does not age out malformed or half-written markers.
+@@ -89,6 +101,19 @@ class SoftFileLock(BaseFileLock):
+             if hostname == socket.gethostname() and not self._is_process_alive(pid):
+                 self._take_over(marker, pid)
+ 
++    def _marker_unchanged_for(self, marker: os.stat_result) -> float:
++        # The mtime is the writer's or file server's clock, so against time.time() a contender whose clock runs ahead
++        # would expire a fresh marker. Only watch it for change, timed on this process's monotonic clock. The owner of a
++        # file sets its mtime, and ext4 reuses a freed inode number, so a new marker can repeat both. The change time
++        # cannot be set and moves with every create, which keeps a new marker from inheriting an old one's age.
++        now: Final = time.monotonic()
++        key: Final = marker.st_mtime, marker.st_ino, marker.st_ctime_ns
++        if (seen := self._marker_seen) is None or seen[0] != key:
++            self._marker_seen = key, now
++            return 0.0
++        return now - seen[1]
++
++
+     def _try_break_expired_lock(self) -> None:
+         if (lifetime := self._context.lifetime) is None:
+             return
+@@ -122,6 +147,10 @@ class SoftFileLock(BaseFileLock):
+         exchanged = held = False
+         try:
+             self._write_lock_info(fd)
++            # The scan and the write take long enough for a peer to have taken the marker since the check above, and an
++            # exchange now would move that peer's live marker aside.
++            if (current := os.lstat(self.lock_file)).st_mtime != marker.st_mtime or current.st_ino != marker.st_ino:
++                return
+             if not exchange_names(aside, self.lock_file):
+                 return  # NFS, SMB and the like refuse it; renaming the marker aside would empty the path
+             exchanged = True
+@@ -222,14 +251,17 @@ class SoftFileLock(BaseFileLock):
+ 
+     def _abandon_take_over(self, fd: int, aside: str, *, put_back: bool) -> None:  # pragma: win32 no cover
+         self._context.lock_file_fd = None
++        put_back_done = not put_back
+         try:
+             if put_back:
+-                exchange_names(aside, self.lock_file)
++                put_back_done = exchange_back(self.lock_file, aside, fd)
+         finally:
+             try:
+                 # Whichever name still holds our marker loses it: the descriptor keeps its inode number ours.
+                 own: Final = os.fstat(fd)
+                 for name in (self.lock_file, aside):
++                    if name == self.lock_file and not put_back_done:
++                        continue
+                     with suppress(FileNotFoundError):
+                         if os.path.samestat(os.lstat(name), own):
+                             Path(name).unlink()
+@@ -399,4 +431,27 @@ def _parse_lock_holder(content: str | None) -> tuple[int, str] | None:
+     return (pid, hostname) if 1 <= pid <= 2**31 - 1 else None
+ 
+ 
++def _placeholder_breaker(content: str | None) -> int | None:  # pragma: win32 no cover
++    # The placeholder a break leaves at the path while it works names the breaker that may have died there.
++    if content is None or not content.startswith(placeholder := "filelock/break\n") or not content.endswith("\n"):
++        return None
++    with suppress(ValueError):
++        return parse_decimal(content.removeprefix(placeholder)[:-1])
++    return None
++
++
++def parse_decimal(text: str) -> int:
++    """
++    Read a PID or start token as a writer formats it: ASCII digits only.
++
++    :raises ValueError: for anything else ``int()`` would accept, such as ``1_000``, surrounding whitespace, a sign or
++        non-ASCII digits; no filelock writes those
++
++    """
++    if not (text.isascii() and text.isdigit()):
++        msg = f"not a decimal integer: {text!r}"
++        raise ValueError(msg)
++    return int(text)
++
++
+ __all__ = ["SoftFileLock"]
+diff --git a/src/filelock/_util.py b/src/filelock/_util.py
+index 98d4f6f..55990c3 100644
+--- a/src/filelock/_util.py
++++ b/src/filelock/_util.py
+@@ -6,6 +6,7 @@ import platform
+ import secrets
+ import stat
+ import sys
++import time
+ from contextlib import contextmanager, suppress
+ from errno import EACCES, EINVAL, EIO, EISDIR, ENOSYS, EXDEV
+ from functools import cache
+@@ -195,8 +196,7 @@ def _swap_out(lock_file: str, break_path: str, mtime_before: float, ino_before:
+             if os.path.samestat(os.lstat(lock_file), os.fstat(fd)):
+                 Path(lock_file).unlink()
+         else:
+-            exchange_names(break_path, lock_file)
+-            Path(break_path).unlink()
++            exchange_back(lock_file, break_path, fd)
+     finally:
+         os.close(fd)
+ 
+@@ -306,8 +306,40 @@ def _renameat2(libc: ctypes.CDLL) -> Callable[[bytes, bytes, int], int] | None:
+ # the lock counts as held.
+ _MOVES_WITHOUT_EXCHANGE: Final[bool] = sys.platform == "win32"
+ 
++def exchange_back(lock_file: str, aside: str, fd: int) -> bool:  # pragma: win32 no cover
++    # Raced breakers nest: each aside holds the prior marker. Returning out of order can strand a live holder's marker.
++    # A peer may move the path between our check and exchange, so stalled breakers exchange to open any resulting ring.
++    try:
++        settled = _exchange_until_aside(lock_file, aside, os.fstat(fd))
++    except FileNotFoundError:
++        settled = True
++    if settled:
++        Path(aside).unlink(missing_ok=True)
++    return settled
++
++
++def _exchange_until_aside(lock_file: str, aside: str, own: os.stat_result) -> bool:  # pragma: win32 no cover
++    # Peers need a few system calls to unwind; a paused or dead peer must not block the caller indefinitely.
++    deadline: Final = time.monotonic() + 2.0
++    stall: Final = 0.01
++    feed_at = time.monotonic() + stall
++    while not os.path.samestat(os.lstat(aside), own):
++        now = time.monotonic()
++        ours = os.path.samestat(os.lstat(lock_file), own)
++        if not ours and now >= deadline:
++            return False
++        if ours or now >= feed_at:
++            if not exchange_names(aside, lock_file):
++                return False
++            feed_at = now + stall
++        else:
++            time.sleep(0.0005)
++    return True
++
++
+ __all__ = [
+     "break_lock_file",
++    "exchange_back",
+     "exchange_names",
+     "exchange_supported",
+     "pin_inode",
+diff --git a/tests/test_soft_security.py b/tests/test_soft_security.py
+index 3da9bd1..0923b23 100644
+--- a/tests/test_soft_security.py
++++ b/tests/test_soft_security.py
+@@ -1,15 +1,20 @@
+ from __future__ import annotations
+ 
++import itertools
+ import os
+ import socket
+ import sys
+ import time
++from contextlib import suppress
+ from pathlib import Path
+ from typing import TYPE_CHECKING, Final
+ 
+ import pytest
+ 
++import filelock
++
+ from filelock import SoftFileLock, Timeout
++from filelock._soft import MALFORMED_LOCK_AGE_THRESHOLD
+ from filelock._util import exchange_names, exchange_supported
+ 
+ if TYPE_CHECKING:
+@@ -133,7 +138,6 @@ def _restore_name(lock: Path, pid: int | str) -> Path:  # pragma: win32 no cover
+     return lock.with_name(f"test.lock.restore.{pid}.{'0' * 32}")
+ 
+ 
+-
+ @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
+ @pytest.mark.parametrize(
+     "recoverer",
+@@ -154,3 +158,172 @@ def test_stale_break_ignores_a_claim_naming_no_valid_pid(  # pragma: win32 no co
+         assert held.is_locked
+ 
+     assert planted.read_text(encoding="utf-8") == foreign
++
++
++def _break_contents(lock: Path) -> list[str]:  # pragma: win32 no cover
++    return [path.read_text(encoding="utf-8") for path in lock.parent.glob(f"{lock.name}.break.*")]
++
++
++def _lose_to_a_live_holder(  # pragma: win32 no cover
++    lock: Path, live: str, mocker: MockerFixture, then: Callable[[str, str], bool | None]
++) -> None:
++    (holder := lock.with_name("holder")).write_text(live, encoding="utf-8")
++
++    def exchange_after_the_holder_won(aside: str, path: str) -> bool:
++        mocker.patch("filelock._soft.exchange_names", new=exchange_names)
++        holder.replace(lock)
++        exchange_names(aside, path)
++        then(aside, path)
++        return True
++
++    mocker.patch("filelock._soft.exchange_names", new=exchange_after_the_holder_won)
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
++def test_stale_break_exchanges_back_after_the_breaker_that_took_its_marker(  # pragma: win32 no cover
++    tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture
++) -> None:
++    (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8")
++    (peer := tmp_path / "peer").write_text(record.replace(_HOST, "elsewhere"), encoding="utf-8")
++
++    def peer_exchanges_back(_seconds: float) -> None:
++        exchange_names(str(peer), str(lock))
++        peer.unlink()
++
++    _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, path: exchange_names(str(peer), path))
++    mocker.patch("filelock._util.time", wraps=time, sleep=mocker.Mock(side_effect=peer_exchanges_back))
++    with pytest.raises(Timeout):
++        lock_type(lock).acquire(blocking=False)
++
++    assert (lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (_live(record), [lock])
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
++def test_stale_break_opens_a_ring_of_breakers_holding_each_others_markers(  # pragma: win32 no cover
++    tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture
++) -> None:
++    (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8")
++    foreign: Final = record.replace(_HOST, "elsewhere")
++    (peer := tmp_path / "peer").write_text(foreign, encoding="utf-8")
++
++    def peer_then_our_exchange_out_of_turn(aside: str, path: str) -> None:
++        exchange_names(str(peer), path)
++        exchange_names(aside, path)
++
++    def peer_exchanges_back_once_it_stands_at_the_path(_seconds: float) -> None:
++        if lock.read_text(encoding="utf-8") == foreign:
++            exchange_names(str(peer), str(lock))
++            peer.unlink()
++
++    _lose_to_a_live_holder(lock, _live(record), mocker, peer_then_our_exchange_out_of_turn)
++    mocker.patch(
++        "filelock._util.time", wraps=time, sleep=mocker.Mock(side_effect=peer_exchanges_back_once_it_stands_at_the_path)
++    )
++    with pytest.raises(Timeout):
++        lock_type(lock).acquire(blocking=False)
++
++    assert (lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (_live(record), [lock])
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
++def test_stale_break_keeps_every_name_when_the_path_never_comes_back(  # pragma: win32 no cover
++    tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture
++) -> None:
++    (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8")
++    foreign: Final = record.replace(_HOST, "elsewhere")
++    (peer := tmp_path / "peer").write_text(foreign, encoding="utf-8")
++    _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, path: exchange_names(str(peer), path))
++    with pytest.raises(Timeout):
++        lock_type(lock).acquire(blocking=False)
++
++    assert sorted([lock.read_text(encoding="utf-8"), *_break_contents(lock)]) == sorted([foreign, _live(record)])
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
++def test_stale_break_keeps_its_marker_when_exchange_back_is_refused(  # pragma: win32 no cover
++    tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture
++) -> None:
++    (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8")
++    _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, _path: None)
++    mocker.patch("filelock._util.exchange_names", side_effect=[False, AssertionError("retried refused exchange")])
++    with pytest.raises(Timeout):
++        lock_type(lock).acquire(blocking=False)
++
++    assert (f"{os.getpid()}\n" in lock.read_text(encoding="utf-8"), _break_contents(lock)) == (
++        True,
++        [_live(record)],
++    )
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
++def test_stale_break_drops_what_it_took_when_the_holder_released_the_path(  # pragma: win32 no cover
++    tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture
++) -> None:
++    (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8")
++    _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, path: Path(path).unlink())
++    with pytest.raises(Timeout):
++        lock_type(lock).acquire(blocking=False)
++
++    assert list(tmp_path.iterdir()) == []
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
++def test_stale_break_leaves_a_marker_taken_since_its_first_look_on_the_path(  # pragma: win32 no cover
++    tmp_path: Path,
++    lock_type: type[SoftFileLock],
++    record: str,
++    mocker: MockerFixture,
++    on_break_recheck: Callable[[Path, Callable[[], None]], None],
++) -> None:
++    (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8")
++    (holder := tmp_path / "holder").write_text(_live(record), encoding="utf-8")
++    exchange: Final = mocker.spy(filelock._soft, "exchange_names")
++
++    def peer_takes_the_marker() -> None:
++        holder.replace(lock)
++
++    on_break_recheck(lock, peer_takes_the_marker)
++    with pytest.raises(Timeout):
++        lock_type(lock).acquire(blocking=False)
++
++    assert (exchange.call_count, lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (
++        0,
++        _live(record),
++        [lock],
++    )
++
++
++@pytest.fixture
++def unchanged_past_grace(mocker: MockerFixture) -> None:
++    # Each look at the marker lands a full grace window after the previous one on the observer's clock, so an unchanged
++    # malformed marker ages out on the second look without the test sleeping through the window.
++    mocker.patch("filelock._soft.time.monotonic", side_effect=itertools.count(step=MALFORMED_LOCK_AGE_THRESHOLD))
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names")
++@pytest.mark.usefixtures("unchanged_past_grace")
++def test_stale_break_restores_a_marker_a_dead_breaker_left_beside_its_placeholder(  # pragma: win32 no cover
++    tmp_path: Path, lock_type: type[SoftFileLock], record: str
++) -> None:
++    (lock := tmp_path / "test.lock").write_text(f"filelock/break\n{_DEAD_PID}\n", encoding="utf-8")
++    _break_name(lock).write_text(_live(record), encoding="utf-8")
++    contender: Final = lock_type(lock)
++    for _ in range(2):
++        with suppress(Timeout):
++            contender.acquire(blocking=False)
++
++    assert (contender.is_locked, lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*"))) == (
++        False,
++        _live(record),
++        [],
++    )
++
++
++def test_expired_takeover_with_read_only_explicit_mode(tmp_path: Path) -> None:
++    lock_path = tmp_path / "test.lock"
++    lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8")
++    os.utime(lock_path, (0, 0))
++    lock = SoftFileLock(lock_path, mode=0o400, lifetime=1, timeout=0)
++    with lock:
++        assert lock.is_locked
++        assert lock_path.stat().st_mode & 0o777 == 0o400
diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb
index 03c23092b9..352ab29a29 100644
--- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb
+++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb
@@ -9,6 +9,9 @@ CVE_PRODUCT = "tox-dev:filelock"
 
 SRC_URI += " file://run-ptest \
     file://CVE-2026-112939.patch \
+    file://CVE-2026-112935_p1.patch \
+    file://CVE-2026-112935_p2.patch \
+    file://CVE-2026-112935_p3.patch \
 "
 SRC_URI[sha256sum] = "b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"
 
@@ -21,6 +24,7 @@ DEPENDS += "\
 "
 
 RDEPENDS:${PN} += " \
+    python3-ctypes \
     python3-core \
     python3-logging \
     python3-asyncio \
