Skip to content

Commit 5f467bb

Browse files
committed
ngclient: Refactor lock file implementation
Signed-off-by: Jussi Kukkonen <jkukkonen@google.com>
1 parent ba0842f commit 5f467bb

File tree

2 files changed

+61
-54
lines changed

2 files changed

+61
-54
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import logging
2+
import time
3+
from collections.abc import Iterator
4+
from contextlib import contextmanager
5+
from typing import IO
6+
7+
logger = logging.getLogger(__name__)
8+
9+
try:
10+
# advisory file locking for posix
11+
import fcntl
12+
13+
@contextmanager
14+
def lock_file(path: str) -> Iterator[IO]:
15+
with open(path, "wb") as f:
16+
fcntl.lockf(f, fcntl.LOCK_EX)
17+
yield f
18+
19+
except ModuleNotFoundError:
20+
# Windows file locking, in belt-and-suspenders-from-Temu style:
21+
# Use a loop that tries to open the lockfile for 30 secs, but also
22+
# use msvcrt.locking().
23+
# * since open() usually just fails when another process has the file open
24+
# msvcrt.locking() almost never gets called when there is a lock. open()
25+
# sometimes succeeds for multiple processes though
26+
# * msvcrt.locking() does not even block until file is available: it just
27+
# tries once per second in a non-blocking manner for 10 seconds. So if
28+
# another process keeps opening the file it's unlikely that we actually
29+
# get the lock
30+
import msvcrt
31+
32+
@contextmanager
33+
def lock_file(path: str) -> Iterator[IO]:
34+
err = None
35+
locked = False
36+
for _ in range(100):
37+
try:
38+
with open(path, "wb") as f:
39+
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
40+
locked = True
41+
yield f
42+
return
43+
except FileNotFoundError:
44+
# could be from yield or from open() -- either way we bail
45+
raise
46+
except OSError as e:
47+
if locked:
48+
# yield has raised, let's not continue loop
49+
raise e
50+
err = e
51+
logger.warning("Unsuccessful lock attempt for %s: %s", path, e)
52+
time.sleep(0.3)
53+
54+
# raise the last failure if we never got a lock
55+
if err is not None:
56+
raise err

tuf/ngclient/updater.py

Lines changed: 5 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,13 @@
5858
import os
5959
import shutil
6060
import tempfile
61-
import time
6261
from pathlib import Path
63-
from typing import IO, TYPE_CHECKING, cast
62+
from typing import TYPE_CHECKING, cast
6463
from urllib import parse
6564

6665
from tuf.api import exceptions
6766
from tuf.api.metadata import Root, Snapshot, TargetFile, Targets, Timestamp
67+
from tuf.ngclient._internal.file_lock import lock_file
6868
from tuf.ngclient._internal.trusted_metadata_set import TrustedMetadataSet
6969
from tuf.ngclient.config import EnvelopeType, UpdaterConfig
7070
from tuf.ngclient.urllib3_fetcher import Urllib3Fetcher
@@ -76,55 +76,6 @@
7676

7777
logger = logging.getLogger(__name__)
7878

79-
try:
80-
# advisory file locking for posix
81-
import fcntl
82-
83-
@contextlib.contextmanager
84-
def _lock_file(path: str) -> Iterator[IO]:
85-
with open(path, "wb") as f:
86-
fcntl.lockf(f, fcntl.LOCK_EX)
87-
yield f
88-
89-
except ModuleNotFoundError:
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
100-
import msvcrt
101-
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
127-
12879

12980
class Updater:
13081
"""Creates a new ``Updater`` instance and loads trusted root metadata.
@@ -204,7 +155,7 @@ def _lock_metadata(self) -> Iterator[None]:
204155
"""Context manager for locking the metadata directory."""
205156

206157
logger.debug("Getting metadata lock...")
207-
with _lock_file(os.path.join(self._dir, ".lock")):
158+
with lock_file(os.path.join(self._dir, ".lock")):
208159
yield
209160
logger.debug("Released metadata lock")
210161

@@ -274,7 +225,7 @@ def get_targetinfo(self, target_path: str) -> TargetFile | None:
274225

275226
with self._lock_metadata():
276227
if Targets.type not in self._trusted_set:
277-
# refresh
228+
# implicit refresh
278229
self._load_root()
279230
self._load_timestamp()
280231
self._load_snapshot()
@@ -367,7 +318,7 @@ def download_target(
367318
targetinfo.verify_length_and_hashes(target_file)
368319

369320
target_file.seek(0)
370-
with _lock_file(filepath) as destination_file:
321+
with lock_file(filepath) as destination_file:
371322
shutil.copyfileobj(target_file, destination_file)
372323

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

0 commit comments

Comments
 (0)