Skip to content

fslocking: fix broken exclusivity on cygwin, see #7218 - #10157

Merged
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:fslocking-by-me-readdir
Aug 19, 2026
Merged

fslocking: fix broken exclusivity on cygwin, see #7218#10157
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:fslocking-by-me-readdir

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Aug 19, 2026

Copy link
Copy Markdown
Member

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 times
  • a failed rename() left no side effects (16000 attempts: temp dir intact, lock dir untouched)

The problem is by_me(). acquire() treats a failed rename() as "already locked" and then returns successfully if by_me() says the lock is ours:

except OSError:  # already locked
    if self.by_me():
        return self

and by_me() was a stat() call (Path.exists()).

Instrumenting both return paths of acquire() caught a violation in the act:

ACQ_BY_ME tid=18  mine=differenthost.1234-12  exists=False/False in_listdir=False  ['differenthost.1234-1c']

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, while os.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 via rename(), 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() implementation cygwin (709 probes) FreeBSD (85739 probes)
via stat() (before) 18 false positives 0
via stat() twice 1 false positive 0
via reading the directory (this PR) 0 0

Checking 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_condition failed 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:

  • baseline without this change: 0 of 30 race test runs failed — the race does not happen there.
  • with this change: 0 of 30 failed, fslocking_test.py + storelocking_test.py 35 passed.
  • full test suite with this change: 2639 passed, 975 skipped, no failures.

Not run on Linux/macOS.

Not addressed here

is_locked() uses the same stat() 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

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

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.96%. Comparing base (6cef09f) to head (8ecf15c).
⚠️ Report is 17 commits behind head on master.
✅ All tests successful. No failed tests found.

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.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann
ThomasWaldmann merged commit b058853 into borgbackup:master Aug 19, 2026
22 of 23 checks passed
@ThomasWaldmann
ThomasWaldmann deleted the fslocking-by-me-readdir branch August 19, 2026 19:38
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.

1 participant