Repository navigation
π fix(soft-rw): keep record writes inside the lock's tree - #842
Merged
Merged
Conversation
gaborbernat
force-pushed
the
fix/soft-rw-storage
branch
from
October 16, 2026 02:06
ed80d41 to
84fcfac
Compare
gaborbernat
force-pushed
the
fix/strict-directories
branch
from
October 16, 2026 02:06
2a3a747 to
8286769
Compare
gaborbernat
force-pushed
the
fix/strict-directories
branch
from
October 16, 2026 02:37
8286769 to
6016b69
Compare
gaborbernat
force-pushed
the
fix/soft-rw-storage
branch
from
October 16, 2026 02:37
84fcfac to
3825c2b
Compare
gaborbernat
force-pushed
the
fix/strict-directories
branch
from
October 16, 2026 03:09
6016b69 to
ab8822c
Compare
gaborbernat
force-pushed
the
fix/soft-rw-storage
branch
from
October 16, 2026 03:09
3825c2b to
f6b747d
Compare
gaborbernat
force-pushed
the
fix/strict-directories
branch
from
October 16, 2026 05:12
2a350ea to
57f1083
Compare
gaborbernat
force-pushed
the
fix/soft-rw-storage
branch
from
October 16, 2026 05:12
f6b747d to
31ba46b
Compare
gaborbernat
force-pushed
the
fix/strict-directories
branch
from
October 16, 2026 05:29
57f1083 to
683a5c1
Compare
gaborbernat
force-pushed
the
fix/soft-rw-storage
branch
from
October 16, 2026 05:29
31ba46b to
af27439
Compare
gaborbernat
force-pushed
the
fix/strict-directories
branch
from
October 16, 2026 05:56
683a5c1 to
07d1f72
Compare
gaborbernat
force-pushed
the
fix/soft-rw-storage
branch
from
October 16, 2026 05:56
af27439 to
95340f3
Compare
gaborbernat
force-pushed
the
fix/strict-directories
branch
from
October 16, 2026 05:57
07d1f72 to
6e118ef
Compare
gaborbernat
force-pushed
the
fix/soft-rw-storage
branch
2 times, most recently
from
October 16, 2026 05:57
95340f3 to
f526dec
Compare
gaborbernat
force-pushed
the
fix/strict-directories
branch
from
October 16, 2026 06:23
6e118ef to
2998551
Compare
gaborbernat
force-pushed
the
fix/soft-rw-storage
branch
from
October 16, 2026 06:23
f526dec to
9e26193
Compare
gaborbernat
force-pushed
the
fix/soft-rw-storage
branch
2 times, most recently
from
October 16, 2026 08:38
0b6baa2 to
af83fbe
Compare
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.
gaborbernat
force-pushed
the
fix/strict-directories
branch
from
October 16, 2026 13:48
2998551 to
09f5b5e
Compare
gaborbernat
force-pushed
the
fix/soft-rw-storage
branch
from
October 16, 2026 13:48
af83fbe to
a77f5e9
Compare
gaborbernat
added a commit
that referenced
this pull request
Oct 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SoftReadWriteLockkeeps its whole state as files under<path>.rw: generation records ingenand one holder record per participant inholders. π Two of its storage operations trusted a name they had checked earlier. A tree swapped in after the first acquire redirected the lock's creates, renames and unlinks into the directory a symlink or junction named. The holder heartbeat wrote through, or blocked on, any file renamed over its record. A swapped tree also ended the heartbeat thread without a report. The fixes live in the storage and protocol layers. Stacked on #839. This resolves GHSA-wfhq-q4j2-c37m and GHSA-fj95-fr6c-h845.Records reached by name through a swapped tree
OsFiles.preparechecked withlstatthat<path>.rw,genandholdersare real directories. It did so once per lock object since #799 added the_preparedshortcut, and every latercreate,link,replace,unlinkandlistdirresolved the full path again.O_NOFOLLOWapplies to the last path component alone (open(2)). Between two acquisitions of a long-lived lock there was no timing to win: when another process renamed the checked tree aside and put one in its place whosegenwas a symlink into a directory of the lock's own account, the nextwrite_lock()returned without error after creatingepochand two generation records there and renaming a record over a file namedHEAD. Withholdersswapped instead, the sweep unlinked files with 32-hex-digit names in the target. The lock refused the planted tree in a fresh process, so the check existed but no live instance consulted it again. The generation log came with #738.sequenceDiagram participant W as Worker (one lock object) participant O as Other process participant T as Target directory rect rgb(207, 228, 255) W->>W: first write_lock(): prepare() checks the tree, remembers it end rect rgb(255, 214, 214) O->>O: renames <path>.rw aside, plants a tree with gen -> T W->>T: second write_lock(): creates epoch and generations in T W->>T: replace() renames a record over T/HEAD end rect rgb(216, 243, 220) Note over W: now each operation opens <path>.rw and gen without following symlinks and works relative to them endWhere the platform supports
dir_fd(openat(2)), every record operation now opens<path>.rwand thengenorholderswithO_NOFOLLOWand acts relative to that descriptor. A swapped-in symlink fails with aTreeReplacedError, aRuntimeErrorsubclass, andpreparecreates and inspects the directories the same way. Each operation also compares the device and inode of the<path>.rw,genandholdersit opened with the onespreparerecorded, and repeats the ownership check from #839 when they differ. A live lock object therefore meets a tree another account created after a recovery the way a fresh one does. No descriptor outlives the operation, so the documented recovery of renaming<path>.rwaside still moves live instances to a fresh log. Each operation costs two extra directory opens and onefstatper opened directory, which may show on network filesystems.Windows has no
dir_fd, andos.lstatreports a junction (a Windows directory that points at another directory) as a directory with the reparse flag set. There the directory check now refuses a reparse point, and each operation holds the directories open without following one and without sharing deletion, so no process can rename or replace them until the operation finishes. Other platforms withoutdir_fdkeep the path-based calls.Holder refresh wrote through any file at the record's name
A holder refreshes its record on every poll of a waiting acquire and on every heartbeat by reopening
holders/<token>by name and writing at offset zero. Since #738 that open was a plain blockingO_WRONLY | O_NOFOLLOWwith nofstatof what it opened, whileread()next to it usedO_NONBLOCKand a regular-file check.O_NOFOLLOWrefuses a symlink but not a hard link (a second name for the same file) or another file renamed over the name. Where the kernel permits linking a file one does not own, a hard link renamed over the record received the holder record in its first 135 bytes while the holder saw an ordinaryTimeout.A FIFO (a named pipe) renamed there needs no such setting: opening a FIFO for writing blocks until a reader appears, so
write_lock(timeout=2)did not return. A holder's heartbeat thread stayed asleep the same way,on_compromisedid not run, and afterstale_thresholda second process took the lock while the first received no report.sequenceDiagram participant H as Holder (heartbeat) participant O as Other process rect rgb(255, 214, 214) H->>H: create holders/<token>, close it O->>O: renames a hard link or FIFO over holders/<token> H->>H: overwrite(): open by name, write at offset 0 Note over H: hard link, another file overwritten. FIFO, open blocks past any timeout end rect rgb(216, 243, 220) H->>H: now open with O_NONBLOCK, fstat, compare device and inode, require one link Note over H: anything else counts as the record being gone: contender polls on, holder reports evicted endcreatenow returns the record's device and inode (the kernel's identity for a file, independent of its name), and the participant keeps them. The refresh opens withO_NONBLOCKand writes only when the descriptor is that regular file with a single link. The protocol does not link holder records, so a second name also marks a file that inherited a removed record's inode number. A FIFO with no reader fails the non-blocking open withENXIO, which counts as the record being gone: a contender keeps polling, and a holder reportslost, which surfaces as theevictedcompromise it would get for a deleted record. TheFilesprotocol signature changes to match; it is internal to_soft_rw. On Windows the same identity check refuses a hard-link swap, and named pipes cannot live in a regular directory, so there is no FIFO case there.A swapped tree ended the heartbeat without a report
The refresh opens
<path>.rwthrough a descriptor and raisesRuntimeErrorfor a symlink or junction found there.Participant.heartbeat()handledOSErroronly, so the exception ended the heartbeat thread,on_compromisenever ran, and the holder kept reporting its generation. The renamed-aside tree is the recovery the how-to describes. If a symlink or junction sat at<path>.rwfor one heartbeat and was removed later, a new process started a fresh log and acquired the write lock while the first holder still believed it held it. Before the descriptor route, the by-name open followed the link, found no record and reportedevicted.sequenceDiagram participant H as First holder (heartbeat) participant O as Other process participant S as Second holder rect rgb(255, 214, 214) O->>O: renames <path>.rw aside, puts a symlink at <path>.rw H->>H: refresh raises RuntimeError, thread ends Note over H: compromise stays None, no report O->>O: removes the symlink S->>S: acquire_write() starts a new log at generation 1 Note over H,S: two holders, the first never told end rect rgb(216, 243, 220) Note over H: now the refresh raises TreeReplacedError, heartbeat() returns lost H->>H: report evicted through on_compromise endThe storage layer raises
TreeReplacedError, aRuntimeErrorsubclass, when a protocol directory is a symlink, a junction, a file or belongs to another account.heartbeat()returnslostfor it, as for a removed record, and treats it like anOSErrorwhen reading the log or sweeping.leave()returns without raising, since a record that cannot be reached has nothing to commit out of or remove. The refresh loop also reports any other exception fromheartbeat()asrefresh-failed, so the thread does not end with the hold still published.LeaseCompromise.erroraccepts anyExceptionfor that reason.Behavior changes
A symlink or junction swapped in for
<path>.rw,genorholdersnow fails the operation withRuntimeErrorinstead of following it. On Windows a rename of<path>.rwduring an operation fails withPermissionErrorand succeeds once the operation ends. A holder whose record another file replaced, or whose<path>.rwa symlink or junction replaced, reportsevictedat its next heartbeat rather than writing into that file. A lock object that used a tree earlier now refuses a replacement tree owned by another account under a sticky parent, as a new object does.LeaseCompromise.erroris typedException | None.