fslocking: fix broken exclusivity on cygwin, see #7218 - #10157
Merged
ThomasWaldmann merged 1 commit intoAug 19, 2026
Conversation
ExclusiveLock.acquire() considers a failed rename() as "already locked" and
then returns successfully if by_me() tells that the lock is ours. by_me() was
implemented via a stat() call (Path.exists()).
On cygwin, stat() transiently succeeds for a path inside a directory that is
concurrently being replaced via rename(): it then disagrees with what reading
that directory gives. So by_me() sometimes claimed a lock as ours that we
never acquired and multiple threads ended up inside the critical section.
Reading the directory does not show that inconsistency, so use that instead.
Measured on cygwin (win11, py3.12), 709 probes, each of which must be False:
by_me() via stat() 18 false positives
by_me() via stat() twice 1 false positive
by_me() via reading the dir 0 false positives
Note that checking twice is not good enough, so really read the directory.
TestExclusiveLock.test_race_condition on cygwin: it failed in ~20% of the runs
before and passes 30 of 30 runs now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #10157 +/- ##
==========================================
+ Coverage 86.94% 86.96% +0.01%
==========================================
Files 101 101
Lines 17983 17986 +3
Branches 2737 2737
==========================================
+ Hits 15636 15641 +5
+ Misses 1639 1638 -1
+ Partials 708 707 -1 ☔ View full report in Codecov by Harness. |
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.
Fixes the locking race of #7218 (the part you called "relatively serious").
Root cause
Not the
rename()primitive — I checked that first and it is fine on cygwin:rename(temp_dir, non_empty_lock_dir)correctly failed 24000 / 24000 timesrename()left no side effects (16000 attempts: temp dir intact, lock dir untouched)The problem is
by_me().acquire()treats a failedrename()as "already locked" and then returns successfully ifby_me()says the lock is ours:and
by_me()was astat()call (Path.exists()).Instrumenting both return paths of
acquire()caught a violation in the act:The complete history of thread 18 is that single event — it had never acquired the lock, so its unique file was never in the lock dir (which held thread 28's file), and nothing else ever removes another thread's file. Immediately re-checking gives False.
Reduced to a standalone test:
os.path.exists("lock/never.15")returns True for a name that was never put into that directory by anybody, whileos.listdir()of the same directory does not show it.So on cygwin
stat()transiently succeeds for a path inside a directory that is concurrently being replaced viarename(), and it disagrees with reading that directory. Two threads then both believe they hold the lock.Fix
Read the directory instead of calling
stat(). Measured with a probe where every check must yield False:by_me()implementationstat()(before)stat()twiceChecking twice is not good enough, which is why this really reads the directory. The anomaly does not exist on FreeBSD at all, which matches this only ever having been reported on cygwin.
Testing
cygwin (win11, py3.12):
TestExclusiveLock::test_race_conditionfailed in ~20% of runs before (2/10 and 1/8 measured), passes 30 of 30 runs with this change.fslocking_test.py+storelocking_test.py: 35 passed.FreeBSD 15.1 (py3.14.7), as a POSIX cross-check:
fslocking_test.py+storelocking_test.py35 passed.Not run on Linux/macOS.
Not addressed here
is_locked()uses the samestat()based check, on the lock directory itself, so it can lie in the same way. I left that alone on purpose: a false "locked" only causes some extra waiting, it does not break exclusivity. Worth a follow-up.🤖 Generated with Claude Code