Skip to content

Commit ba0842f

Browse files
committed
ngclient: Fix the lockfile handling in Windows
There does not seem to be a way around a ugly loop over open()... Signed-off-by: Jussi Kukkonen <jkukkonen@google.com>
1 parent cbe34d9 commit ba0842f

File tree

5 files changed

+82
-51
lines changed

5 files changed

+82
-51
lines changed

tests/refresh_script.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import sys
2+
import time
23

34
from tuf.ngclient import Updater
45

56
print(f"Fetching metadata {sys.argv[1]} times:")
67
print(f" metadata dir: {sys.argv[2]}")
78
print(f" metadata url: {sys.argv[3]}")
89

10+
start = time.time()
911

1012
for i in range(int(sys.argv[1])):
1113
try:
14+
refresh_start = time.time()
1215
u = Updater(metadata_dir=sys.argv[2], metadata_base_url=sys.argv[3])
1316
# file3.txt is delegated so we end up exercising all metadata load paths
1417
u.get_targetinfo("file3.txt")
1518
except OSError as e:
16-
sys.exit(f"Failed on iteration {i}: {e}")
19+
print(
20+
f"Failed on iteration {i}, "
21+
f"{time.time() - refresh_start} secs elapsed ({time.time() - start} total)"
22+
)
23+
raise e

tests/test_updater_ng.py

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -356,16 +356,14 @@ def test_user_agent(self) -> None:
356356

357357
self.assertEqual(ua[:23], "MyApp/1.2.3 python-tuf/")
358358

359-
360-
class TestParallelUpdater(TestUpdater):
361359
def test_parallel_updaters(self) -> None:
362-
# Refresh two updaters in parallel many times, using the same local metadata cache.
360+
# Refresh many updaters in parallel many times, using the same local metadata cache.
363361
# This should reveal race conditions.
364362

365-
iterations = 100
363+
iterations = 50
364+
process_count = 10
366365

367-
# The project root is the parent of the tests directory
368-
project_root = os.path.dirname(utils.TESTS_DIR)
366+
project_root_dir = os.path.dirname(utils.TESTS_DIR)
369367

370368
command = [
371369
sys.executable,
@@ -376,29 +374,26 @@ def test_parallel_updaters(self) -> None:
376374
self.metadata_url,
377375
]
378376

379-
p1 = subprocess.Popen(
380-
command,
381-
stdout=subprocess.PIPE,
382-
stderr=subprocess.PIPE,
383-
cwd=project_root,
384-
)
385-
p2 = subprocess.Popen(
386-
command,
387-
stdout=subprocess.PIPE,
388-
stderr=subprocess.PIPE,
389-
cwd=project_root,
390-
)
391-
392-
stdout1, stderr1 = p1.communicate()
393-
stdout2, stderr2 = p2.communicate()
377+
procs = [
378+
subprocess.Popen(
379+
command,
380+
stdout=subprocess.PIPE,
381+
stderr=subprocess.PIPE,
382+
cwd=project_root_dir,
383+
)
384+
for _ in range(process_count)
385+
]
394386

395-
if p1.returncode != 0 or p2.returncode != 0:
387+
errout = ""
388+
for proc in procs:
389+
stdout, stderr = proc.communicate()
390+
if proc.returncode != 0:
391+
errout += "Parallel Refresh script failed:"
392+
errout += f"\nprocess stdout: \n{stdout.decode()}"
393+
errout += f"\nprocess stderr: \n{stderr.decode()}"
394+
if errout:
396395
self.fail(
397-
"Parallel refresh failed"
398-
f"\nprocess 1 stdout: \n{stdout1.decode()}"
399-
f"\nprocess 1 stderr: \n{stderr1.decode()}"
400-
f"\nprocess 2 stdout: \n{stdout2.decode()}"
401-
f"\nprocess 2 stderr: \n{stderr2.decode()}"
396+
f"One or more scripts failed parallel refresh test:\n{errout}"
402397
)
403398

404399

tests/test_updater_validation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ def test_local_target_storage_fail(self) -> None:
5353

5454
def test_non_existing_metadata_dir(self) -> None:
5555
with self.assertRaises(FileNotFoundError):
56-
# Initialize Updater with non-existing metadata_dir
56+
# Initialize Updater with non-existing metadata_dir and no bootstrap root
5757
Updater(
58-
"non_existing_metadata_dir",
58+
f"{self.temp_dir.name}/non_existing_metadata_dir",
5959
"https://example.com/metadata/",
6060
fetcher=self.sim,
6161
)

tox.ini

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ skipsdist = true
1111
[testenv]
1212
commands =
1313
python3 --version
14-
python3 -m coverage run -m unittest
15-
python3 -m coverage report -m --fail-under 97
14+
python3 -m coverage run -m unittest -v
15+
python3 -m coverage report -m --fail-under 96
1616

1717
deps =
1818
-r{toxinidir}/requirements/test.txt

tuf/ngclient/updater.py

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
import os
5959
import shutil
6060
import tempfile
61+
import time
6162
from pathlib import Path
6263
from typing import IO, TYPE_CHECKING, cast
6364
from urllib import parse
@@ -79,21 +80,50 @@
7980
# advisory file locking for posix
8081
import fcntl
8182

82-
def _lock_file(f: IO) -> None:
83-
if f.writable():
83+
@contextlib.contextmanager
84+
def _lock_file(path: str) -> Iterator[IO]:
85+
with open(path, "wb") as f:
8486
fcntl.lockf(f, fcntl.LOCK_EX)
87+
yield f
8588

8689
except ModuleNotFoundError:
87-
# Windows file locking
90+
# Windows file locking, in belt-and-suspenders-from-Temu style:
91+
# Use a loop that tries to open the lockfile for 30 secs, but also
92+
# use msvcrt.locking().
93+
# * since open() usually just fails when another process has the file open
94+
# msvcrt.locking() almost never gets called when there is a lock. open()
95+
# sometimes succeeds for multiple processes though
96+
# * msvcrt.locking() does not even block until file is available: it just
97+
# tries once per second in a non-blocking manner for 10 seconds. So if
98+
# another process keeps opening the file it's unlikely that we actually
99+
# get the lock
88100
import msvcrt
89101

90-
def _lock_file(f: IO) -> None:
91-
# On Windows we lock a byte range and file must not be empty
92-
f.write(b"\0")
93-
f.flush()
94-
f.seek(0)
95-
96-
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
102+
@contextlib.contextmanager
103+
def _lock_file(path: str) -> Iterator[IO]:
104+
err = None
105+
locked = False
106+
for _ in range(100):
107+
try:
108+
with open(path, "wb") as f:
109+
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
110+
locked = True
111+
yield f
112+
return
113+
except FileNotFoundError:
114+
# could be from yield or from open() -- either way we bail
115+
raise
116+
except OSError as e:
117+
if locked:
118+
# yield has raised, let's not continue loop
119+
raise e
120+
err = e
121+
logger.warning("Unsuccessful lock attempt for %s: %s", path, e)
122+
time.sleep(0.3)
123+
124+
# raise the last failure if we never got a lock
125+
if err is not None:
126+
raise err
97127

98128

99129
class Updater:
@@ -153,6 +183,10 @@ def __init__(
153183
f"got '{self.config.envelope_type}'"
154184
)
155185

186+
# Ensure the whole metadata directory structure exists
187+
rootdir = Path(self._dir, "root_history")
188+
rootdir.mkdir(exist_ok=True, parents=True)
189+
156190
with self._lock_metadata():
157191
if not bootstrap:
158192
# if no root was provided, use the cached non-versioned root
@@ -168,15 +202,11 @@ def __init__(
168202
@contextlib.contextmanager
169203
def _lock_metadata(self) -> Iterator[None]:
170204
"""Context manager for locking the metadata directory."""
171-
# Ensure the whole metadata directory structure exists
172-
rootdir = Path(self._dir, "root_history")
173-
rootdir.mkdir(exist_ok=True, parents=True)
174205

175-
with open(os.path.join(self._dir, ".lock"), "wb") as f:
176-
logger.debug("Getting metadata lock...")
177-
_lock_file(f)
206+
logger.debug("Getting metadata lock...")
207+
with _lock_file(os.path.join(self._dir, ".lock")):
178208
yield
179-
logger.debug("Releasing metadata lock")
209+
logger.debug("Released metadata lock")
180210

181211
def refresh(self) -> None:
182212
"""Refresh top-level metadata.
@@ -337,8 +367,7 @@ def download_target(
337367
targetinfo.verify_length_and_hashes(target_file)
338368

339369
target_file.seek(0)
340-
with open(filepath, "wb") as destination_file:
341-
_lock_file(destination_file)
370+
with _lock_file(filepath) as destination_file:
342371
shutil.copyfileobj(target_file, destination_file)
343372

344373
logger.debug("Downloaded target %s", targetinfo.path)

0 commit comments

Comments
 (0)