Skip to content

Commit 8ae4aec

Browse files
committed
feat(robot): implement cross-platform file locking for data cache
Replace the Unix-only flock-based locking with a platform-dispatched implementation using LockFileEx/UnlockFileEx (Windows) and fcntl.flock (Unix/macOS). The Windows path previously had no-op early returns, meaning the exclusive lock used by `cache prune` provided no actual protection on Windows.
1 parent 1d283aa commit 8ae4aec

2 files changed

Lines changed: 119 additions & 47 deletions

File tree

packages/robot/src/robotcode/robot/diagnostics/data_cache.py

Lines changed: 104 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010

1111
from ..utils import get_robot_version_str
1212

13+
if sys.platform == "win32":
14+
import ctypes
15+
import msvcrt
16+
from ctypes import wintypes
17+
else:
18+
import fcntl
19+
1320
_M = TypeVar("_M")
1421
_D = TypeVar("_D")
1522

@@ -83,23 +90,103 @@ def data(self) -> _D:
8390

8491
CACHE_DIR_NAME = ".robotcode_cache"
8592
_LOCK_FILE_NAME = "cache.lock"
93+
_LOCK_LENGTH = 1
94+
95+
96+
if sys.platform == "win32":
97+
_LOCKFILE_FAIL_IMMEDIATELY = 0x00000001
98+
_LOCKFILE_EXCLUSIVE_LOCK = 0x00000002
99+
_ERROR_LOCK_VIOLATION = 33
100+
_ERROR_IO_PENDING = 997
101+
102+
class _Overlapped(ctypes.Structure):
103+
_fields_ = [
104+
("Internal", ctypes.c_void_p),
105+
("InternalHigh", ctypes.c_void_p),
106+
("Offset", wintypes.DWORD),
107+
("OffsetHigh", wintypes.DWORD),
108+
("hEvent", wintypes.HANDLE),
109+
]
86110

111+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
112+
_kernel32.LockFileEx.argtypes = [
113+
wintypes.HANDLE,
114+
wintypes.DWORD,
115+
wintypes.DWORD,
116+
wintypes.DWORD,
117+
wintypes.DWORD,
118+
ctypes.POINTER(_Overlapped),
119+
]
120+
_kernel32.LockFileEx.restype = wintypes.BOOL
121+
_kernel32.UnlockFileEx.argtypes = [
122+
wintypes.HANDLE,
123+
wintypes.DWORD,
124+
wintypes.DWORD,
125+
wintypes.DWORD,
126+
ctypes.POINTER(_Overlapped),
127+
]
128+
_kernel32.UnlockFileEx.restype = wintypes.BOOL
129+
130+
131+
def _open_lock_file(cache_dir: Path) -> int:
132+
lock_path = cache_dir / _LOCK_FILE_NAME
133+
return os.open(str(lock_path), os.O_RDWR | os.O_CREAT, 0o666)
87134

88-
def _acquire_shared_lock(cache_dir: Path) -> Optional[int]:
89-
"""Acquire a shared advisory lock on the cache directory.
90135

91-
Returns the file descriptor on Unix, None on Windows
92-
(Windows already prevents deletion of open files).
93-
"""
94-
if sys.platform == "win32":
95-
return None
136+
if sys.platform == "win32":
96137

97-
import fcntl
138+
def _lock_file(fd: int, *, exclusive: bool, blocking: bool) -> bool:
139+
flags = 0
140+
if exclusive:
141+
flags |= _LOCKFILE_EXCLUSIVE_LOCK
142+
if not blocking:
143+
flags |= _LOCKFILE_FAIL_IMMEDIATELY
98144

99-
lock_path = cache_dir / _LOCK_FILE_NAME
100-
fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, 0o666)
145+
overlapped = _Overlapped()
146+
handle = wintypes.HANDLE(msvcrt.get_osfhandle(fd))
147+
result = _kernel32.LockFileEx(handle, flags, 0, _LOCK_LENGTH, 0, ctypes.byref(overlapped))
148+
if result:
149+
return True
150+
151+
error = ctypes.get_last_error()
152+
if not blocking and error in {_ERROR_LOCK_VIOLATION, _ERROR_IO_PENDING}:
153+
return False
154+
155+
raise ctypes.WinError(error)
156+
157+
def _unlock_file(fd: int) -> None:
158+
overlapped = _Overlapped()
159+
handle = wintypes.HANDLE(msvcrt.get_osfhandle(fd))
160+
result = _kernel32.UnlockFileEx(handle, 0, _LOCK_LENGTH, 0, ctypes.byref(overlapped))
161+
if not result:
162+
raise ctypes.WinError(ctypes.get_last_error())
163+
164+
165+
else:
166+
167+
def _lock_file(fd: int, *, exclusive: bool, blocking: bool) -> bool:
168+
flags = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
169+
if not blocking:
170+
flags |= fcntl.LOCK_NB
171+
172+
try:
173+
fcntl.flock(fd, flags)
174+
except OSError:
175+
if not blocking:
176+
return False
177+
raise
178+
179+
return True
180+
181+
def _unlock_file(fd: int) -> None:
182+
fcntl.flock(fd, fcntl.LOCK_UN)
183+
184+
185+
def _acquire_shared_lock(cache_dir: Path) -> Optional[int]:
186+
"""Acquire a shared lock on the cache directory."""
187+
fd = _open_lock_file(cache_dir)
101188
try:
102-
fcntl.flock(fd, fcntl.LOCK_SH)
189+
_lock_file(fd, exclusive=False, blocking=True)
103190
except Exception:
104191
os.close(fd)
105192
raise
@@ -111,10 +198,7 @@ def _release_lock(fd: Optional[int]) -> None:
111198
if fd is None:
112199
return
113200
try:
114-
if sys.platform != "win32":
115-
import fcntl
116-
117-
fcntl.flock(fd, fcntl.LOCK_UN)
201+
_unlock_file(fd)
118202
finally:
119203
os.close(fd)
120204

@@ -127,26 +211,20 @@ def exclusive_cache_lock(cache_dir: Path) -> Iterator[bool]:
127211
False if another process holds a shared lock (cache is in use).
128212
The lock is held until the context manager exits.
129213
"""
130-
if sys.platform == "win32":
131-
yield True
132-
return
133-
134-
import fcntl
135-
136214
lock_path = cache_dir / _LOCK_FILE_NAME
137215
if not lock_path.exists():
138216
yield True
139217
return
140218

141219
fd = os.open(str(lock_path), os.O_RDWR)
220+
acquired = False
142221
try:
143-
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
144-
yield True
145-
except OSError:
146-
yield False
222+
acquired = _lock_file(fd, exclusive=True, blocking=False)
223+
yield acquired
147224
finally:
148225
try:
149-
fcntl.flock(fd, fcntl.LOCK_UN)
226+
if acquired:
227+
_unlock_file(fd)
150228
except OSError:
151229
pass
152230
os.close(fd)

tests/robotcode/robot/diagnostics/test_data_cache_locking.py

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
"""Tests for SqliteDataCache advisory file locking."""
1+
"""Tests for SqliteDataCache file locking."""
22

3+
import os
34
import sys
45
from pathlib import Path
56

@@ -12,8 +13,6 @@
1213
exclusive_cache_lock,
1314
)
1415

15-
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="Advisory file locking is Unix-only")
16-
1716

1817
class TestSharedLocking:
1918
def test_lock_file_created_on_open(self, tmp_path: Path) -> None:
@@ -27,7 +26,6 @@ def test_two_caches_can_open_same_dir(self, tmp_path: Path) -> None:
2726
cache1 = SqliteDataCache(cache_dir)
2827
cache2 = SqliteDataCache(cache_dir)
2928

30-
# Both can read and write
3129
cache1.save_entry(CacheSection.LIBRARY, "e1", None, "data1")
3230
cache2.save_entry(CacheSection.LIBRARY, "e2", None, "data2")
3331

@@ -67,13 +65,11 @@ def test_exclusive_blocked_by_multiple_shared(self, tmp_path: Path) -> None:
6765

6866
cache1.close()
6967

70-
# Still blocked by cache2
7168
with exclusive_cache_lock(cache_dir) as acquired:
7269
assert not acquired
7370

7471
cache2.close()
7572

76-
# Now should succeed
7773
with exclusive_cache_lock(cache_dir) as acquired:
7874
assert acquired
7975

@@ -88,7 +84,6 @@ def test_exclusive_succeeds_when_no_cache_open(self, tmp_path: Path) -> None:
8884
def test_exclusive_succeeds_when_no_lock_file(self, tmp_path: Path) -> None:
8985
cache_dir = tmp_path / "cache"
9086
cache_dir.mkdir(parents=True)
91-
# No lock file exists
9287

9388
with exclusive_cache_lock(cache_dir) as acquired:
9489
assert acquired
@@ -100,19 +95,18 @@ def test_exclusive_lock_held_during_context(self, tmp_path: Path) -> None:
10095

10196
with exclusive_cache_lock(cache_dir) as acquired:
10297
assert acquired
103-
# While we hold exclusive, a new shared lock should block
104-
# (opening a new SqliteDataCache would try LOCK_SH which
105-
# is compatible with LOCK_EX held by the same process on Linux,
106-
# so we test with a raw flock instead)
107-
import fcntl
108-
import os
98+
if sys.platform != "win32":
99+
import fcntl
109100

110-
fd = os.open(str(cache_dir / _LOCK_FILE_NAME), os.O_RDWR)
111-
try:
112-
with pytest.raises(BlockingIOError):
113-
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
114-
finally:
115-
os.close(fd)
101+
fd = os.open(str(cache_dir / _LOCK_FILE_NAME), os.O_RDWR)
102+
try:
103+
with pytest.raises(BlockingIOError):
104+
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
105+
finally:
106+
os.close(fd)
107+
else:
108+
with exclusive_cache_lock(cache_dir) as nested_acquired:
109+
assert not nested_acquired
116110

117111

118112
class TestLockingWithPrune:
@@ -126,7 +120,8 @@ def test_prune_safe_when_cache_closed(self, tmp_path: Path) -> None:
126120

127121
with exclusive_cache_lock(cache_dir) as acquired:
128122
assert acquired
129-
shutil.rmtree(cache_dir)
123+
124+
shutil.rmtree(cache_dir)
130125

131126
assert not cache_dir.exists()
132127

@@ -136,7 +131,6 @@ def test_prune_blocked_when_cache_open(self, tmp_path: Path) -> None:
136131

137132
with exclusive_cache_lock(cache_dir) as acquired:
138133
assert not acquired
139-
# Cache dir should still exist
140134
assert cache_dir.exists()
141135

142136
cache.close()

0 commit comments

Comments
 (0)