Visitar URL original
🐛 fix(soft-rw): keep stalled peers from a second hold by gaborbernat · Pull Request #845 · tox-dev/filelock · GitHub
Skip to content

🐛 fix(soft-rw): keep stalled peers from a second hold - #845

Merged
gaborbernat merged 13 commits into
fix/strict-directoriesfrom
fix/soft-rw-protocol
Oct 17, 2026
Merged

gaborbernat merged 13 commits into
fix/strict-directoriesfrom
fix/soft-rw-protocol

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

SoftReadWriteLock could grant a lock beside a live writer in two ways that involve only cooperating processes of one account. 🐛 A commit that landed far below the head after two committers stalled at adjacent generations, and a stale check that counted a waiter's own pause as time the holder's record stayed unchanged. Both let a second process enter while the first still holds, and the first learns of it only at its next heartbeat. Stacked on #842.

A commit lands a whole window behind the log

A commit refuses a link into a slot that compaction already freed by checking, before and after the link, that the previous generation still exists; #815 added _extends_the_log for the single-stall case. Two committers stalled before their links to adjacent slots N-1 and N, while peers made 66 or more commits so that both slots fell out of the retained window, defeat that check. The lower committer linked its late record into its freed slot and took it back only after its own post-link check failed. In that window the upper committer found gen/<N-1> present, so its commit returned True about 70 generations below HEAD, and advance() admitted it as writer while HEAD still named the real holder.

The stall only has to span the window. Between the pre-link check and the link, _publish creates, writes and fsyncs the temporary record, the one step of a commit that waits for storage, so a paused or frozen process is enough. The generation log and this guard came with #738 and #815.

sequenceDiagram
    participant D as Committer D (slot 2)
    participant E as Committer E (slot 3)
    participant P as Peers
    rect rgb(207, 228, 255)
        D->>D: passes pre-link check, stalls before link
        E->>E: passes pre-link check, stalls before link
        P->>P: commit generations 3 to 73, compaction frees slots 2 and 3
    end
    rect rgb(255, 214, 214)
        D->>D: links late record into slot 2
        E->>E: links into slot 3, reads gen/2: present
        Note over E: commit() returns True, E enters beside the writer at 73
        D->>D: reads gen/1: gone, removes gen/2
    end
Loading

A slot comes free once the log is a whole window past it, and HEAD stays, so after the link a commit is now also refused when the newest listed generation or HEAD leads it by a window. The lock consults HEAD as well because an NFS client may serve the listing from its cache. A refused record that peers lead by less than a window stays in place, because removing it would free its slot early for a third stalled committer; the lock still removes a record that has no successor. The cost is one listing and one read of HEAD per commit.

A paused waiter evicts a live holder

A contender judged a holder stale by reading its record and only then sampling the clock, and the ledger stamped an unchanged value with that late sample. Any pause between the read and the sample counted as time the record stayed unchanged. A waiting process stopped by job control or a frozen container for about stale_threshold minus the age of the nonce it last saw (60 to 90 s with the defaults) therefore saw a record "unchanged" although the holder rewrote it several times meanwhile.

On resume, advance() marked the holder stale, committed the next generation for the contender and unlinked the holder's record. Both then acted as sole holder until the holder's next heartbeat, up to heartbeat_interval (30 s by default), reported evicted. The window is a few microseconds per poll in a single-threaded contender and wider when other threads hold the GIL. The read-then-sample order dates from #738. The eviction rule it broke is the one the concepts page states: a record unchanged for stale_threshold.

sequenceDiagram
    participant H as Holder
    participant C as Contender
    rect rgb(207, 228, 255)
        C->>C: read holders/H: nonce n1, sample clock t
        H->>H: heartbeat: nonce n2
        C->>C: read holders/H: still returns n1
    end
    rect rgb(255, 214, 214)
        Note over C: paused for stale_threshold before sampling the clock
        H->>H: heartbeats: n3, n4
        C->>C: clock now t + 90.25: n1 "unchanged" for 90.25 s
        C->>H: evicts H, commits next generation, unlinks holders/H
    end
Loading

The contender now samples time.monotonic() before the read, and the ledger vouches for an unchanged record only up to that moment, so a stall after the fetch adds nothing. A changed record still gets the sample taken after the read: stamping it with the earlier sample would move the error to the other side, where a stall before the fetch backdates the new value and the next poll counts the stall. Both ends now err toward alive. _unchanged_for_its_lease is the one place that computes the stale verdict, so the sweep of unnamed records follows the same rule.

@gaborbernat
gaborbernat force-pushed the fix/soft-rw-protocol branch 3 times, most recently from aa0f03a to 8fce37a Compare October 16, 2026 08:23
@gaborbernat
gaborbernat force-pushed the fix/soft-rw-storage branch 2 times, most recently from 0b6baa2 to af83fbe Compare October 16, 2026 08:38
@gaborbernat
gaborbernat force-pushed the fix/soft-rw-protocol branch 2 times, most recently from 5d0d754 to 9903e69 Compare October 16, 2026 13:43
SoftReadWriteLock checks once per lock object that <path>.rw, gen and
holders are real directories, then resolves every record by full path.
O_NOFOLLOW covers the last component alone. When another process renamed
the tree aside between two acquisitions and put one with a symlinked gen
or holders in its place, the lock created, renamed over and unlinked its
records in the directory the symlink named.

Every record operation now opens <path>.rw and then gen or holders without
following symlinks and acts relative to that descriptor. A swapped-in
symlink fails the call with the RuntimeError prepare() already raises for
it. No descriptor outlives the call, so a later call still finds a log
removed and started again by name. Platforms without dir_fd keep the
path-based calls.
A holder refreshes its record by reopening holders/<token> by name and
writing at offset zero. The open blocked, and nothing checked what it
opened. A hard link (a second name for the same file) renamed over the
record received the holder record. A FIFO (a named pipe) renamed there
left a waiting acquire past its timeout and a holder's heartbeat thread
asleep without a compromise report.

create() now returns the record's device and inode, and the refresh opens
with O_NONBLOCK and writes only when the descriptor is that regular file
with a single name. Anything else counts as the record being gone: a
contender keeps polling and a holder reports itself evicted.
Windows has no dir_fd, so SoftReadWriteLock keeps reaching its records by
name there. lstat reports a junction (a directory that points at another
directory) as a directory. The lock adopted a junction in place of gen or
holders on first use, and one swapped in after an earlier acquire
redirected its creates, renames and unlinks.

The directory check now refuses a reparse point. Each record operation
holds <path>.rw and gen or holders open while it runs, without following
a reparse point and without sharing deletion. No process can rename or
replace either directory until the operation finishes.
Comments, docstrings, the concepts page and the changelog entry now define
each term where it first appears, use short active sentences and leave
out what the code already says.
The heartbeat opens `<path>.rw` through a descriptor and raised a plain
RuntimeError for a symlink or junction. heartbeat() handled OSError
only, so the thread ended with the hold still published and
on_compromise never ran.

Add TreeReplacedError, raised by the storage refusals, and have
heartbeat() report the hold lost and leave() return when it is raised.
The refresh loop also reports any other heartbeat failure as
refresh-failed instead of ending the thread.
prepare() judged the ownership of `<path>.rw`, `gen` and `holders` once,
and a live lock object skipped it afterwards. After the documented
recovery renamed the tree aside, a tree created by another account was
used without the check a fresh object makes.

OsFiles now records the device and inode of each directory prepare()
checked. Every record operation compares the directory it opened with
that identity and repeats the ownership check when they differ.
commit() refuses a link into a slot compaction freed by checking that the
previous generation still exists, before and after the link. Two
committers stalled before their links to adjacent slots, while peers made
66 or more commits, defeated that check. The lower one linked its late
record into its freed slot. The upper one found that record as its
predecessor before the lower one took it back, so its commit succeeded
about 70 generations below the head and its writer entered beside the
real holder.

A slot comes free once the log is a whole window past it, and the head
stays. After the link the lock now also refuses a record that the listing
or HEAD leads by a window. A refused record that peers lead by less than a
window stays in place, since removing it would free its slot early for a
third stalled committer.
A contender judged a holder stale by reading its record and only then
sampling the clock. A pause between the two, such as a stopped or frozen
waiter, counted as time the record stayed unchanged although the holder
rewrote it several times meanwhile. The contender then evicted a live
holder and took the lock beside it.

The contender now samples the clock before the read, and an unchanged
record counts as unchanged up to that sample. A changed record still gets
the sample after the read, so both ends err toward alive.
The commit and ledger docstrings, a comment and the changelog entry now use
short active sentences and leave out what the code already says.
Base automatically changed from fix/soft-rw-storage to fix/strict-directories October 17, 2026 10:10
@gaborbernat
gaborbernat merged commit 4483246 into fix/strict-directories Oct 17, 2026
41 of 42 checks passed
@gaborbernat
gaborbernat deleted the fix/soft-rw-protocol branch October 17, 2026 10:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant