Skip to content

Commit f2f022a

Browse files
authored
Merge pull request #4790 from mwichmann/test/filelock
Add some basic unit tests for file locking
2 parents 8c15e8c + b9aaf8b commit f2f022a

File tree

4 files changed

+169
-31
lines changed

4 files changed

+169
-31
lines changed

CHANGES.txt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ NOTE: Since SCons 4.9.0, Python 3.7.0 or above is required.
1313
RELEASE VERSION/DATE TO BE FILLED IN LATER
1414

1515
From John Doe:
16-
17-
- Whatever John Doe did.
16+
- Whatever John Doe did.
1817

1918
From William Deegan:
20-
- Fix SCons Docbook schema to work with lxml > 5
19+
- Fix SCons Docbook schema to work with lxml > 5
20+
21+
From Mats Wichmann:
22+
- Introduce some unit tests for the file locking utility routines
2123

2224

2325
RELEASE 4.10.1 - Sun, 16 Nov 2025 10:51:57 -0700

RELEASE.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ DEVELOPMENT
6161

6262
- List visible changes in the way SCons is developed
6363

64+
- Introduce some unit tests for the file locking utility routines
65+
6466
Thanks to the following contributors listed below for their contributions to this release.
6567
==========================================================================================
6668
.. code-block:: text

SCons/Util/filelock.py

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
which acquires a lock (or at least, permission) on entry and
99
releases it on exit.
1010
11+
The model is to prevent concurrency problems if there are multiple
12+
SCons processes running which may try to read/write the same
13+
externally shared resource, such as a cache file. They don't
14+
have to be the same machine in the case of a network share,
15+
so we can't use OS-specific locking schemes like fcntl/msvcrt.
16+
1117
Usage::
1218
1319
from SCons.Util.filelock import FileLock
@@ -19,13 +25,16 @@
1925

2026
# TODO: things to consider.
2127
# Is raising an exception the right thing for failing to get lock?
22-
# Is a filesystem lockfile scheme sufficient for our needs?
23-
# - or is it better to put locks on the actual file (fcntl/windows-based)?
24-
# ... Is that even viable in the case of a remote (network) file?
28+
# Is this scheme sufficient for our needs? Or do we need the extra
29+
# level of creating a "claim file" first and then trying to link
30+
# that to the lockfile? Bonus: a claim file can contain details, like
31+
# how long before another process is allowed to break the lock,
32+
# plus identifying the creator.
2533
# Is this safe enough? Or do we risk dangling lockfiles?
2634
# Permission issues in case of multi-user. This *should* be okay,
2735
# the cache usually goes in user's homedir, plus you already have
28-
# enough rights for the lockfile if the dir lets you create the cache.
36+
# enough rights for the lockfile if the dir lets you create the cache,
37+
# but manual permission fiddling may be needed for network shares.
2938
# Need a forced break-lock method?
3039
# The lock attributes could probably be made opaque. Showed one visible
3140
# in the example above, but not sure the benefit of that.
@@ -37,36 +46,46 @@
3746

3847

3948
class SConsLockFailure(Exception):
40-
"""Lock failure exception."""
49+
"""Generic lock failure exception."""
50+
51+
class AlreadyLockedError(SConsLockFailure):
52+
"""Non-blocking lock attempt on already locked object."""
53+
54+
class LockTimeoutError(SConsLockFailure):
55+
"""Blocking lock attempt timed out."""
4156

4257

4358
class FileLock:
44-
"""Lock a file using a lockfile.
59+
"""Lock a resource using a lockfile.
4560
46-
Basic locking for when multiple processes may hit an externally
47-
shared resource that cannot depend on locking within a single SCons
48-
process. SCons does not have a lot of those, but caches come to mind.
61+
Basic locking for when multiple processes may hit an externally shared
62+
resource (i.e. a file) that cannot depend on locking within a single
63+
SCons process. SCons does not have a lot of those, but caches come to
64+
mind. Note that the resource named by *file* does not need to exist.
4965
50-
Cross-platform safe, does not use any OS-specific features. Provides
66+
Cross-platform safe, does not use any OS-specific features. Is not
67+
safe from outside interference - a "locked" resource can be messed
68+
with by anyone who doesn't pay attention to this protocol. Provides
5169
context manager support, or can be called with :meth:`acquire_lock`
5270
and :meth:`release_lock`.
5371
5472
Lock can be a write lock, which is held until released, or a read
55-
lock, which releases immediately upon aquisition - we want to not
56-
read a file which somebody else may be writing, but not create the
57-
writers starvation problem of the classic readers/writers lock.
73+
lock, which releases immediately upon acquisition - we want to prevent
74+
reading a file while somebody else may be writing to it, but once we
75+
get that permission, we don't want to block others from reading it
76+
too, or asking for a write lock on it.
5877
5978
TODO: Should default timeout be None (non-blocking), or 0 (block forever),
60-
or some arbitrary number?
79+
or some arbitrary number? For now it's non-blocking.
6180
6281
Arguments:
6382
file: name of file to lock. Only used to build the lockfile name.
64-
timeout: optional time (sec) to give up trying.
65-
If ``None``, quit now if we failed to get the lock (non-blocking).
83+
timeout: optional time (sec) after which to give up trying.
84+
If ``None``, the lock attempt will be non-blocking (the default).
6685
If 0, block forever (well, a long time).
6786
delay: optional delay between tries [default 0.05s]
68-
writer: if True, obtain the lock for safe writing. If False (default),
69-
just wait till the lock is available, give it back right away.
87+
writer: if true, obtain and hold the lock for safe writing.
88+
If false (the default), release the lock right away.
7089
7190
Raises:
7291
SConsLockFailure: if the operation "timed out", including the
@@ -97,8 +116,10 @@ def __init__(
97116
self.writer = writer
98117

99118
def acquire_lock(self) -> None:
100-
"""Acquire the lock, if possible.
119+
"""Acquire the lock.
101120
121+
Blocks until the lock is acquired, unless the lock object
122+
was initialized to not block.
102123
If the lock is in use, check again every *delay* seconds.
103124
Continue until lock acquired or *timeout* expires.
104125
"""
@@ -108,12 +129,12 @@ def acquire_lock(self) -> None:
108129
self.lock = os.open(self.lockfile, os.O_CREAT|os.O_EXCL|os.O_RDWR)
109130
except (FileExistsError, PermissionError) as exc:
110131
if self.timeout is None:
111-
raise SConsLockFailure(
132+
raise AlreadyLockedError(
112133
f"Could not acquire lock on {self.file!r}"
113134
) from exc
114135
if (time.perf_counter() - start_time) > self.timeout:
115-
raise SConsLockFailure(
116-
f"Timeout waiting for lock on {self.file!r}."
136+
raise LockTimeoutError(
137+
f"Timeout waiting for lock on {self.file!r} for {self.timeout} seconds."
117138
) from exc
118139
time.sleep(self.delay)
119140
else:
@@ -131,21 +152,30 @@ def release_lock(self) -> None:
131152

132153
def __enter__(self) -> FileLock:
133154
"""Context manager entry: acquire lock if not holding."""
134-
if not self.lock:
155+
if self.lock is None:
135156
self.acquire_lock()
136157
return self
137158

138159
def __exit__(self, exc_type, exc_value, exc_tb) -> None:
139160
"""Context manager exit: release lock if holding."""
140-
if self.lock:
161+
if self.lock is not None:
141162
self.release_lock()
142163

143164
def __repr__(self) -> str:
144165
"""Nicer display if someone repr's the lock class."""
145166
return (
146-
f"{self.__class__.__name__}("
167+
f"<{self.__class__.__name__}("
147168
f"file={self.file!r}, "
148-
f"timeout={self.timeout!r}, "
149-
f"delay={self.delay!r}, "
150-
f"writer={self.writer!r})"
169+
f"type={'writer, ' if self.writer else 'reader, '}"
170+
f"lock={'unlocked' if self.lock is None else 'locked'}, "
171+
f"timeout={self.timeout}, "
172+
f"delay={self.delay}, "
173+
f"pid={os.getpid()}) "
174+
f"at 0x{id(self):x}>"
151175
)
176+
177+
# Local Variables:
178+
# tab-width:4
179+
# indent-tabs-mode:nil
180+
# End:
181+
# vim: set expandtab tabstop=4 shiftwidth=4:

SCons/Util/filelockTests.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# MIT License
2+
#
3+
# Copyright The SCons Foundation
4+
#
5+
# Permission is hereby granted, free of charge, to any person obtaining
6+
# a copy of this software and associated documentation files (the
7+
# "Software"), to deal in the Software without restriction, including
8+
# without limitation the rights to use, copy, modify, merge, publish,
9+
# distribute, sublicense, and/or sell copies of the Software, and to
10+
# permit persons to whom the Software is furnished to do so, subject to
11+
# the following conditions:
12+
#
13+
# The above copyright notice and this permission notice shall be included
14+
# in all copies or substantial portions of the Software.
15+
#
16+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
17+
# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
18+
# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23+
24+
"""File locking tests.
25+
26+
TODO: Unlocking an unlocked file ("works" - no exception raised)
27+
TODO: Test reader locks
28+
TODO: Create another process to better simulate lock contention
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import os
34+
import unittest
35+
from contextlib import suppress
36+
37+
import TestCmd
38+
39+
from SCons.Util import filelock
40+
41+
42+
class TestFileLock(unittest.TestCase):
43+
def setUp(self):
44+
self.test = TestCmd.TestCmd(workdir='')
45+
self.file = self.test.workpath('test.txt')
46+
self.lockfile = self.test.workpath('test.txt.lock')
47+
self.lock = None
48+
49+
def tearDown(self):
50+
self.lock.release_lock()
51+
# in case we made phony lock, clean that up too
52+
with suppress(FileNotFoundError):
53+
os.unlink(self.lockfile)
54+
55+
def _fakelock(self) -> int:
56+
"""Create a fake lockfile to simulate a busy lock."""
57+
return os.open(self.lockfile, os.O_CREAT|os.O_EXCL|os.O_RDWR)
58+
59+
def test_context_manager_lock(self):
60+
"""Test acquiring a file lock, context manager."""
61+
self.lock = filelock.FileLock(self.file, writer=True)
62+
self.assertFalse(os.path.exists(self.lockfile))
63+
with self.lock:
64+
self.assertTrue(os.path.exists(self.lockfile))
65+
self.assertFalse(os.path.exists(self.lockfile))
66+
67+
def test_acquire_release_lock(self):
68+
"""Test acquiring a file lock, method."""
69+
self.lock = filelock.FileLock(self.file, writer=True)
70+
self.lock.acquire_lock()
71+
self.assertTrue(os.path.exists(self.lockfile))
72+
self.lock.release_lock()
73+
self.assertFalse(os.path.exists(self.lockfile))
74+
75+
# Implementation does not raise on release when not holding lock: skip
76+
# def test_exception_raised_when_lock_not_held(self):
77+
# self.lock = filelock.FileLock(self.file, writer=True)
78+
# with self.assertRaises(filelock.SConsLockFailure):
79+
# self.lock.release_lock()
80+
81+
def test_nonblocking_lockfail(self):
82+
"""Test non-blocking acquiring a busy file lock."""
83+
self.lock = filelock.FileLock(self.file, writer=True)
84+
fd = self._fakelock()
85+
with self.lock and self.assertRaises(filelock.AlreadyLockedError):
86+
self.lock.acquire_lock()
87+
os.close(fd)
88+
89+
def test_timeout_lockfail(self):
90+
"""Test blocking acquiring a busy file lock with timeout."""
91+
self.lock = filelock.FileLock(self.file, writer=True, timeout=1)
92+
fd = self._fakelock()
93+
with self.lock and self.assertRaises(filelock.LockTimeoutError):
94+
self.lock.acquire_lock()
95+
os.close(fd)
96+
97+
if __name__ == "__main__":
98+
unittest.main()
99+
100+
# Local Variables:
101+
# tab-width:4
102+
# indent-tabs-mode:nil
103+
# End:
104+
# vim: set expandtab tabstop=4 shiftwidth=4:

0 commit comments

Comments
 (0)