5858import os
5959import shutil
6060import tempfile
61+ import time
6162from pathlib import Path
6263from typing import IO , TYPE_CHECKING , cast
6364from urllib import parse
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
8689except 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(). The latter would be the OS lock but it's
93+ # mostly useless:
94+ # * since open() usually just fails when another process has the file open
95+ # msvcrt.locking() never gets called when there is a lock. open()
96+ # sometimes succeeds for
97+ # multiple processes but write or flush then fails
98+ # * msvcrt.locking() does not even block until file is available: it just
99+ # tries once per second in a non-blocking manner for 10 seconds. So if
100+ # another process keeps opening the file it's unlikely that we actually
101+ # get the lock
88102 import msvcrt
89103
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 )
104+ @contextlib .contextmanager
105+ def _lock_file (path : str ) -> Iterator [IO ]:
106+ err = None
107+ locked = False
108+ for _ in range (100 ):
109+ try :
110+ with open (path , "wb" ) as f :
111+ # file must not be empty
112+ f .write (b"\0 " )
113+ f .flush ()
114+ f .seek (0 )
115+ msvcrt .locking (f .fileno (), msvcrt .LK_LOCK , 1 )
116+ locked = True
117+ yield f
118+ return
119+ except FileNotFoundError :
120+ # could be from yield or from open() -- either way we bail
121+ raise
122+ except OSError as e :
123+ if locked :
124+ # yield has raised, let's not continue loop
125+ raise e
126+ err = e
127+ logger .warning ("Unsuccessful lock attempt for %s: %s" , path , e )
128+ time .sleep (0.3 )
129+
130+ # raise the last failure if we never got a lock
131+ if err is not None :
132+ raise err
97133
98134
99135class Updater :
@@ -153,6 +189,10 @@ def __init__(
153189 f"got '{ self .config .envelope_type } '"
154190 )
155191
192+ # Ensure the whole metadata directory structure exists
193+ rootdir = Path (self ._dir , "root_history" )
194+ rootdir .mkdir (exist_ok = True , parents = True )
195+
156196 with self ._lock_metadata ():
157197 if not bootstrap :
158198 # if no root was provided, use the cached non-versioned root
@@ -168,12 +208,9 @@ def __init__(
168208 @contextlib .contextmanager
169209 def _lock_metadata (self ) -> Iterator [None ]:
170210 """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 )
211+
174212 logger .debug ("Getting metadata lock..." )
175- with open (os .path .join (self ._dir , ".lock" ), "wb" ) as f :
176- _lock_file (f )
213+ with _lock_file (os .path .join (self ._dir , ".lock" )):
177214 yield
178215 logger .debug ("Released metadata lock" )
179216
@@ -336,8 +373,7 @@ def download_target(
336373 targetinfo .verify_length_and_hashes (target_file )
337374
338375 target_file .seek (0 )
339- with open (filepath , "wb" ) as destination_file :
340- _lock_file (destination_file )
376+ with _lock_file (filepath ) as destination_file :
341377 shutil .copyfileobj (target_file , destination_file )
342378
343379 logger .debug ("Downloaded target %s" , targetinfo .path )
0 commit comments