Skip to content

Commit 529eb0b

Browse files
PidFileLock: Add more tests (#105)
Coverage is 96.8% Co-authored-by: Sushant Gaurav <[email protected]>
1 parent ed2792a commit 529eb0b

2 files changed

Lines changed: 267 additions & 17 deletions

File tree

src/docbuild/utils/pidlock.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,23 +122,30 @@ def __exit__(self, exc_type, exc_value, traceback) -> None:
122122

123123
# 1. Release the fcntl lock
124124
try:
125-
# Must use the file descriptor (handle.fileno()) for fcntl.flock
125+
# Must use the file descriptor (handle.fileno()) for fcntl.flockq
126126
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
127-
except Exception:
128-
# Non-critical, but log if needed
129-
pass
127+
except OSError as e:
128+
# Log OS-level failures releasing the lock; don't raise from __exit__
129+
log.warning(
130+
"Failed to release fcntl lock for %s: %s", self.resource_path, e
131+
)
130132

131133
# 2. Close the file handle
132134
try:
133135
handle.close()
134-
except Exception:
135-
pass
136+
except (OSError, ValueError) as e:
137+
# Only handle known close-related errors (OS-level errors and ValueError
138+
# if the file object is in an invalid state). Allow other unexpected
139+
# exceptions to propagate.
140+
log.warning("Failed to close lock file handle %s: %s", self._lock_path, e)
136141

137142
# 3. Remove the lock file
138143
try:
139144
self._lock_path.unlink(missing_ok=True)
140145
log.debug("Released fcntl lock and removed file %s", self._lock_path)
141-
except Exception as e:
146+
except OSError as e:
147+
# Only catch OS-level errors from unlink (e.g. permission issues);
148+
# allow other unexpected exceptions to propagate.
142149
log.warning(f"Failed to remove lock file {self._lock_path}: {e}")
143150

144151
self._handle = None

tests/utils/test_pidlock.py

Lines changed: 253 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
"""Tests for the PidFileLock utility."""
22

33
import errno
4-
import os
5-
import pytest
64
import logging
75
import multiprocessing as mp
8-
import time
9-
import platform
10-
from pathlib import Path
11-
from docbuild.utils.pidlock import PidFileLock, LockAcquisitionError
126
from multiprocessing import Event
7+
import os
8+
from pathlib import Path
9+
import platform
10+
import time
11+
12+
import pytest
13+
from unittest.mock import patch, Mock
14+
import docbuild.utils.pidlock as pidlock_mod
15+
import builtins
16+
from docbuild.utils.pidlock import LockAcquisitionError, PidFileLock
1317

1418
# Define a shared marker to skip the test if the OS is macOS (Darwin)
1519
skip_macos = pytest.mark.skipif(
@@ -35,7 +39,7 @@ def _mp_lock_holder(resource_path: Path, lock_dir: Path, lock_path: Path, done_e
3539
with lock:
3640
lock_path.touch()
3741
done_event.wait()
38-
42+
3943
except Exception:
4044
pass
4145

@@ -69,7 +73,7 @@ def test_context_manager(lock_setup):
6973
assert not lock.lock_path.exists()
7074

7175

72-
@skip_macos
76+
@skip_macos
7377
def test_lock_prevents_concurrent_access_in_separate_process(lock_setup):
7478
"""Test that two separate processes cannot acquire the same lock simultaneously."""
7579

@@ -175,7 +179,7 @@ def test_acquire_critical_oserror(monkeypatch, tmp_path):
175179

176180
# Mock the built-in open to fail with EACCES, simulating a permissions error
177181
def mocked_builtin_open(path, mode):
178-
# We now rely on monkeypatching os.open directly in pidlock.py,
182+
# We now rely on monkeypatching os.open directly in pidlock.py,
179183
# but for this test, we mock builtins.open if os.open is not used directly.
180184
# Since pidlock.py now uses open(self._lock_path, 'w+'), mocking builtins.open is correct
181185
raise OSError(errno.EACCES, "Access denied")
@@ -189,4 +193,243 @@ def mocked_builtin_open(path, mode):
189193
# Check that the RuntimeError message contains the expected text
190194
error_message = str(exc_info.value)
191195
assert "Cannot acquire lock:" in error_message
192-
assert "Access denied" in error_message
196+
assert "Access denied" in error_message
197+
198+
199+
def test_pidfilelock_singleton_per_lock_path(tmp_path):
200+
"""Constructing PidFileLock twice for the same resource should return the same instance."""
201+
resource = tmp_path / "resource.txt"
202+
lock_dir = tmp_path / "locks"
203+
204+
lock1 = PidFileLock(resource, lock_dir=lock_dir)
205+
lock2 = PidFileLock(resource, lock_dir=lock_dir)
206+
207+
# same computed lock_path and same object returned
208+
assert lock1 is lock2
209+
assert lock1.lock_path == lock2.lock_path
210+
211+
212+
def test_flock_eagain_raises_lockacquisitionerror(tmp_path):
213+
"""If fcntl.flock raises EAGAIN/EWOULDBLOCK, a LockAcquisitionError is raised
214+
and the module's cleanup path (closing the opened handle) is executed.
215+
Use a real file handle (no patch of open) so the actual close() source line
216+
in pidlock.py is executed and counted by coverage.
217+
"""
218+
resource = tmp_path / "resource.txt"
219+
lock_dir = tmp_path / "locks"
220+
lock_dir.mkdir()
221+
222+
def raise_eagain(fd, op):
223+
raise OSError(errno.EAGAIN, "Resource temporarily unavailable")
224+
225+
# Only patch the flock call; let pidlock.open create a real file handle so
226+
# the except branch that calls handle.close() runs in pidlock.py.
227+
with patch.object(pidlock_mod.fcntl, "flock", raise_eagain):
228+
with pytest.raises(LockAcquisitionError):
229+
with PidFileLock(resource, lock_dir=lock_dir):
230+
pass
231+
232+
233+
def test_open_eacces_raises_runtimeerror(tmp_path):
234+
"""If opening the lock file fails with EACCES/EPERM, a RuntimeError is raised."""
235+
resource = tmp_path / "resource.txt"
236+
lock_dir = tmp_path / "locks"
237+
238+
def fake_open(*args, **kwargs):
239+
raise OSError(errno.EACCES, "Permission denied")
240+
241+
with patch.object(pidlock_mod, "open", fake_open):
242+
with pytest.raises(RuntimeError, match="Cannot acquire lock"):
243+
with PidFileLock(resource, lock_dir=lock_dir):
244+
pass
245+
246+
247+
def test_open_eacces_raises_runtimeerror_via_builtins(tmp_path):
248+
"""Ensure the EACCES/EPERM open failure branch raises RuntimeError (covers the raise RuntimeError line)."""
249+
resource = tmp_path / "resource.txt"
250+
lock_dir = tmp_path / "locks"
251+
lock_dir.mkdir()
252+
resource.write_text("dummy")
253+
lock = PidFileLock(resource, lock_dir)
254+
255+
def fake_open(*args, **kwargs):
256+
raise OSError(errno.EACCES, "Permission denied (simulated)")
257+
258+
# Patch the actual builtins.open used by pidlock.open(...) to ensure the except branch is hit
259+
with patch.object(builtins, "open", fake_open):
260+
with pytest.raises(RuntimeError, match="Cannot acquire lock"):
261+
with lock:
262+
pass
263+
264+
265+
def test_open_other_oserror_reraises_original_exception(tmp_path):
266+
"""If open() raises an OSError not handled by special branches, it should be re-raised."""
267+
resource = tmp_path / "resource.txt"
268+
lock_dir = tmp_path / "locks"
269+
270+
def fake_open(*args, **kwargs):
271+
raise OSError(errno.EINVAL, "Invalid argument")
272+
273+
with patch.object(pidlock_mod, "open", fake_open):
274+
with pytest.raises(OSError) as excinfo:
275+
with PidFileLock(resource, lock_dir=lock_dir):
276+
pass
277+
278+
# Ensure the original errno is preserved (covers the final `raise e` branch)
279+
assert excinfo.value.errno == errno.EINVAL
280+
281+
282+
def test_enter_handles_flock_eagain_closes_handle(tmp_path):
283+
"""When flock raises EAGAIN after open succeeded, the opened handle is closed and LockAcquisitionError is raised."""
284+
resource = tmp_path / "resource.txt"
285+
lock_dir = tmp_path / "locks"
286+
# Create fake handle and ensure it has a close() we can assert
287+
fake_handle = Mock()
288+
fake_handle.fileno.return_value = 123
289+
fake_handle.seek.return_value = None
290+
fake_handle.truncate.return_value = None
291+
fake_handle.write.return_value = None
292+
fake_handle.flush.return_value = None
293+
fake_handle.close.return_value = None
294+
295+
def raise_eagain(fd, op):
296+
raise OSError(errno.EAGAIN, "Resource temporarily unavailable")
297+
298+
with patch.object(pidlock_mod, "open", lambda *a, **k: fake_handle):
299+
with patch.object(pidlock_mod.fcntl, "flock", raise_eagain):
300+
with pytest.raises(LockAcquisitionError):
301+
with PidFileLock(resource, lock_dir=lock_dir):
302+
pass
303+
304+
fake_handle.close.assert_called_once()
305+
306+
307+
def test_enter_open_eacces_raises_runtimeerror(tmp_path):
308+
"""If module-level open raises EACCES, __enter__ raises a RuntimeError wrapping that OSError."""
309+
resource = tmp_path / "resource.txt"
310+
lock_dir = tmp_path / "locks"
311+
312+
def fake_open(*args, **kwargs):
313+
raise OSError(errno.EACCES, "Permission denied (simulated)")
314+
315+
with patch.object(pidlock_mod, "open", fake_open):
316+
with pytest.raises(RuntimeError, match="Cannot acquire lock"):
317+
with PidFileLock(resource, lock_dir=lock_dir):
318+
pass
319+
320+
321+
def test_exit_flock_unlock_oserror_is_handled(tmp_path):
322+
"""If releasing the fcntl lock (second flock call) raises OSError, __exit__ should catch and log but not raise."""
323+
resource = tmp_path / "resource.txt"
324+
lock_dir = tmp_path / "locks"
325+
lock_dir.mkdir()
326+
calls = {"n": 0}
327+
328+
def flock_first_ok_then_raise(fd, op):
329+
calls["n"] += 1
330+
if calls["n"] == 1:
331+
return None # __enter__ flock succeeds
332+
# second call (unlock) raises
333+
raise OSError(errno.EIO, "I/O error on unlock")
334+
335+
with patch.object(pidlock_mod.fcntl, "flock", flock_first_ok_then_raise):
336+
# Should not raise on context exit even though unlock raises
337+
with PidFileLock(resource, lock_dir=lock_dir):
338+
assert Path(lock_dir).exists()
339+
# after exit, lock file cleaned up (unlink missing_ok may remove it)
340+
assert not PidFileLock(resource, lock_dir).lock_path.exists()
341+
342+
343+
def test_exit_handle_close_raises_is_handled(tmp_path):
344+
"""If handle.close() raises OSError, __exit__ should catch and log and continue cleanup."""
345+
resource = tmp_path / "resource.txt"
346+
lock_dir = tmp_path / "locks"
347+
lock_dir.mkdir()
348+
349+
# fake handle whose close() will raise OSError
350+
class BadHandle:
351+
def fileno(self):
352+
return 1
353+
def seek(self, *_):
354+
return None
355+
def truncate(self, *_):
356+
return None
357+
def write(self, *_):
358+
return None
359+
def flush(self):
360+
return None
361+
def close(self):
362+
raise OSError(errno.EIO, "close failed")
363+
364+
bad_handle = BadHandle()
365+
366+
# Make flock a no-op so __enter__ succeeds
367+
with patch.object(pidlock_mod.fcntl, "flock", lambda *a, **k: None):
368+
with patch.object(pidlock_mod, "open", lambda *a, **k: bad_handle):
369+
# Should not raise on context exit despite close() raising
370+
with PidFileLock(resource, lock_dir=lock_dir):
371+
pass
372+
373+
374+
def test_exit_unlink_raises_oserror_is_handled(tmp_path):
375+
"""If unlink() raises OSError during __exit__, it should be caught and logged without raising."""
376+
resource = tmp_path / "resource.txt"
377+
lock_dir = tmp_path / "locks"
378+
lock_dir.mkdir()
379+
380+
# Use a normal file handle and normal flock to acquire lock
381+
with patch.object(pidlock_mod.fcntl, "flock", lambda *a, **k: None):
382+
lock = PidFileLock(resource, lock_dir=lock_dir)
383+
384+
# Patch the Path.unlink implementation used by pidlock so unlink() raises OSError
385+
def bad_unlink(self, missing_ok=True):
386+
raise OSError(errno.EIO, "unlink failed")
387+
388+
with patch.object(pidlock_mod.Path, "unlink", bad_unlink):
389+
# enter the context; when __exit__ calls unlink(), it will raise and be handled
390+
with lock:
391+
pass
392+
393+
# exit should not raise even though unlink raised
394+
assert not lock._lock_acquired
395+
assert lock._handle is None
396+
397+
398+
def test_enter_open_eacces_on_fresh_instance(tmp_path):
399+
"""Force the EACCES/EPERM branch on a fresh PidFileLock instance to cover the RuntimeError path."""
400+
resource = tmp_path / "resource_eacces.txt"
401+
lock_dir = tmp_path / "locks_eacces"
402+
403+
def fake_open(*args, **kwargs):
404+
raise OSError(errno.EACCES, "Permission denied (forced)")
405+
406+
# Patch the module-level open used by pidlock for this fresh instance
407+
with patch.object(pidlock_mod, "open", fake_open):
408+
with pytest.raises(RuntimeError, match="Cannot acquire lock"):
409+
with PidFileLock(resource, lock_dir=lock_dir):
410+
pass
411+
412+
413+
def test_enter_flock_eagain_no_handle(tmp_path):
414+
"""
415+
Simulate EAGAIN/EWOULDBLOCK being raised when 'handle' is None to hit the
416+
'if handle: handle.close()' branch where 'handle' is None.
417+
"""
418+
419+
resource = tmp_path / 'resource.txt'
420+
lock_dir = tmp_path / 'locks'
421+
422+
# Mock open to return a handle
423+
real_handle = open(tmp_path / 'lockfile.tmp', 'w+')
424+
425+
# Using two mocks: one for open to succeed, one for flock to fail.
426+
# Patch 'open' in a way that allows controlling the assignment of 'handle'.
427+
428+
# Simulate open failing with EAGAIN directly, which also forces 'handle' to be None.
429+
def fake_open_with_eagain(*args, **kwargs):
430+
raise OSError(errno.EAGAIN, 'Simulated open error matching flock failure')
431+
432+
with patch.object(pidlock_mod, 'open', fake_open_with_eagain):
433+
with pytest.raises(LockAcquisitionError, match='Resource is locked'):
434+
with PidFileLock(resource, lock_dir=lock_dir):
435+
pass

0 commit comments

Comments
 (0)