Skip to content

use lstat when checking lifetime so a symlinked lock file is not followed#550

Closed
dxbjavid wants to merge 1 commit into
tox-dev:mainfrom
dxbjavid:api-lifetime-lstat
Closed

use lstat when checking lifetime so a symlinked lock file is not followed#550
dxbjavid wants to merge 1 commit into
tox-dev:mainfrom
dxbjavid:api-lifetime-lstat

Conversation

@dxbjavid
Copy link
Copy Markdown
Contributor

@dxbjavid dxbjavid commented Jun 5, 2026

reading the code I noticed _try_break_expired_lock uses Path(self.lock_file).stat() to decide if a lock is expired, which follows symlinks. a same-host process with write access to the lock directory can swap a held lock file for a symlink onto an old file, so stat sees the stale target mtime and a waiter breaks the live lock and acquires it, so two holders coexist. lstat reads the symlink's own mtime instead, matching the O_NOFOLLOW lock-file reads already in _soft.py and _soft_rw.

@gaborbernat
Copy link
Copy Markdown
Member

Superseded by #551, which keeps your lstat fix and adds the related hardening (verified-inode breaking, self-healing of malformed lock files, async executor cleanup) found while reviewing this. Thanks for opening this and catching the original symlink issue, @dxbjavid! 🙏

@gaborbernat gaborbernat closed this Jun 6, 2026
gaborbernat added a commit that referenced this pull request Jun 6, 2026
…#551)

A process sharing the lock directory on the same host could swap a held
soft lock file for a symlink pointing at an old file, so the lifetime
check saw the target's stale mtime, a waiter broke the still-live lock,
and two processes held it at once. 🔒 This builds on @dxbjavid's #550,
which switched the lifetime check to `os.lstat` so it reads the
symlink's own mtime, and closes the remaining gaps in the same
stale-breaking path.

Breaking a stale lock now claims the file by inode before removing it. A
shared `break_lock_file` helper renames the lock to a process-private
name, re-checks the modification time, and unlinks only when it still
matches the value seen at the stale decision. A newer time means a peer
recreated the lock in the gap, so the helper leaves the file in place
rather than deleting it out from under a live holder. Both the soft lock
and the lifetime path use this helper, replacing the duplicated rename
logic. Malformed lock files self-heal more reliably too: a non-integer
PID or creation time now counts as unparseable, so a two or three line
garbage file gets evicted once it ages past the safety window instead of
wedging acquirers forever.

`AsyncReadWriteLock` gained a destructor that shuts down the
single-thread executor it owns, so forgetting to call `close()` no
longer leaks the worker thread, while a caller-supplied executor stays
untouched. Reviewers should know that releasing a soft lock may now
remove the lock file as part of self-healing, and that filelock evicts
malformed files automatically after a brief safety window. Supersedes
#550. 🙏

---------

Co-authored-by: dxbjavid <dxbjavid@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants