11"""Tests for the PidFileLock utility."""
22
33import errno
4- import os
5- import pytest
64import logging
75import multiprocessing as mp
8- import time
9- import platform
10- from pathlib import Path
11- from docbuild .utils .pidlock import PidFileLock , LockAcquisitionError
126from 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)
1519skip_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
7377def 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