Skip to content

Commit c0ce645

Browse files
committed
fix: wait out a snapshot lock file Windows is still deleting
1 parent 4814dad commit c0ce645

2 files changed

Lines changed: 73 additions & 0 deletions

File tree

‎assertpy2/snapshot.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import contextlib
44
import inspect
55
import os
6+
import sys
67
import time
78
import warnings
89
from dataclasses import replace
@@ -385,6 +386,11 @@ def _file_lock(target: str, *, timeout: float = 10.0, poll: float = 0.05) -> Ite
385386
if time.monotonic() >= deadline:
386387
raise TimeoutError(f"could not acquire snapshot lock <{lockpath}> within {timeout}s") from None
387388
time.sleep(poll)
389+
except PermissionError:
390+
# Windows refuses to create a lock file its last holder is still deleting, as access denied
391+
if sys.platform != "win32" or time.monotonic() >= deadline:
392+
raise
393+
time.sleep(poll)
388394
try:
389395
yield
390396
finally:

‎tests/test_snapshots.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,73 @@ def test_file_lock_times_out_when_held(tmp_path):
10481048
pass
10491049

10501050

1051+
class _Clock:
1052+
"""Stands in for the lock's `time`: only its own sleeps move it, so a deadline falls on a known attempt."""
1053+
1054+
def __init__(self):
1055+
self.now = 0.0
1056+
1057+
def monotonic(self):
1058+
return self.now
1059+
1060+
def sleep(self, seconds):
1061+
self.now += seconds
1062+
1063+
1064+
class TestALockFileBeingDeleted:
1065+
"""Windows answers creating a lock file its last holder is still deleting with access denied.
1066+
1067+
Read as a failure, it took a snapshot write down under xdist whenever two workers met on one key:
1068+
three acquisitions in 1600 across four processes, measured on Windows.
1069+
"""
1070+
1071+
@staticmethod
1072+
def _refusing(monkeypatch, times):
1073+
"""`os.open` refusing the lock *times* times as access denied, then creating it."""
1074+
calls = []
1075+
real = os.open
1076+
1077+
def refusing(path, flags, *mode):
1078+
calls.append(path)
1079+
if len(calls) <= times:
1080+
raise PermissionError(13, "Permission denied", path)
1081+
return real(path, flags, *mode)
1082+
1083+
monkeypatch.setattr(os, "open", refusing)
1084+
return calls
1085+
1086+
@pytest.fixture
1087+
def clock(self, monkeypatch):
1088+
clock = _Clock()
1089+
monkeypatch.setattr(_snapshot, "time", clock)
1090+
return clock
1091+
1092+
def test_windows_waits_for_it(self, tmp_path, monkeypatch, clock):
1093+
monkeypatch.setattr(sys, "platform", "win32")
1094+
calls = self._refusing(monkeypatch, times=2)
1095+
with _file_lock(str(tmp_path / "data"), poll=0.25):
1096+
pass
1097+
assert_that(calls).is_length(3)
1098+
assert_that(clock.now).is_equal_to(0.5)
1099+
1100+
def test_windows_names_a_refusal_that_outlasts_the_wait(self, tmp_path, monkeypatch, clock):
1101+
"""A directory it may not write to is not a lock being released, and says so rather than timing out."""
1102+
monkeypatch.setattr(sys, "platform", "win32")
1103+
calls = self._refusing(monkeypatch, times=10_000)
1104+
with pytest.raises(PermissionError), _file_lock(str(tmp_path / "data"), timeout=1.0, poll=0.25):
1105+
pass
1106+
assert_that(calls).is_length(5)
1107+
assert_that(clock.now).is_equal_to(1.0)
1108+
1109+
@pytest.mark.parametrize("platform", ["linux", "darwin"])
1110+
def test_elsewhere_access_denied_is_what_it_says(self, tmp_path, monkeypatch, platform):
1111+
monkeypatch.setattr(sys, "platform", platform)
1112+
calls = self._refusing(monkeypatch, times=1)
1113+
with pytest.raises(PermissionError), _file_lock(str(tmp_path / "data"), poll=0.001):
1114+
pass
1115+
assert_that(calls).is_length(1)
1116+
1117+
10511118
def test_file_lock_serializes_concurrent_writes(tmp_path):
10521119
target = str(tmp_path / "shared.json")
10531120
with open(target, "w") as fp:

0 commit comments

Comments
 (0)