33from __future__ import annotations
44
55import asyncio
6- import contextlib
76import dataclasses
87import functools
98import getpass
2120import pytest
2221
2322from libvcs import exc
23+ from libvcs ._internal .file_lock import atomic_init
2424from libvcs ._internal .run import _ENV , run
2525from libvcs .sync .git import GitRemote , GitSync
2626from libvcs .sync .hg import HgSync
@@ -149,123 +149,6 @@ def get_vcs_version(cmd: list[str]) -> str:
149149 return "not-installed"
150150
151151
152- # Stale lock timeout (5 minutes - covers slow hg operations)
153- _LOCK_TIMEOUT = 5 * 60
154-
155-
156- def _acquire_lock (lock_path : pathlib .Path ) -> int | None :
157- """Atomically acquire lock file. Returns fd if acquired, None otherwise.
158-
159- Uses filelock's SoftFileLock pattern: os.O_CREAT | os.O_EXCL for atomicity.
160- """
161- try :
162- fd = os .open (
163- str (lock_path ),
164- os .O_WRONLY | os .O_CREAT | os .O_EXCL ,
165- 0o644 ,
166- )
167- except FileExistsError :
168- return None
169- else :
170- # Write PID for debugging stale locks
171- os .write (fd , str (os .getpid ()).encode ())
172- return fd
173-
174-
175- def _release_lock (lock_path : pathlib .Path , fd : int ) -> None :
176- """Release lock file."""
177- os .close (fd )
178- with contextlib .suppress (OSError ):
179- lock_path .unlink ()
180-
181-
182- def _is_lock_stale (lock_path : pathlib .Path ) -> bool :
183- """Check if lock is stale (older than timeout)."""
184- try :
185- mtime = lock_path .stat ().st_mtime
186- return time .time () - mtime > _LOCK_TIMEOUT
187- except OSError :
188- return True
189-
190-
191- def _atomic_repo_init (
192- repo_path : pathlib .Path ,
193- init_fn : t .Callable [[], None ],
194- marker_name : str = ".libvcs_initialized" ,
195- timeout : float = 60.0 ,
196- poll_interval : float = 0.05 ,
197- ) -> bool :
198- """Atomically initialize a repository with file-based lock coordination.
199-
200- Uses filelock-inspired pattern for pytest-xdist worker coordination.
201- Two-file approach: .lock (temporary) vs marker (permanent).
202-
203- Parameters
204- ----------
205- repo_path : pathlib.Path
206- Path to the repository directory to initialize
207- init_fn : Callable[[], None]
208- Function to call to perform initialization (creates repo_path)
209- marker_name : str
210- Name of the marker file indicating successful completion
211- timeout : float
212- Maximum seconds to wait for another process to complete
213- poll_interval : float
214- Seconds between polling attempts (default 50ms like filelock)
215-
216- Returns
217- -------
218- bool
219- True if this process performed initialization, False if waited for another
220- """
221- marker = repo_path / marker_name
222- lock_path = repo_path .parent / f".{ repo_path .name } .lock"
223-
224- # Fast path: already initialized
225- if marker .exists ():
226- return False
227-
228- # Ensure parent directory exists for lock file
229- lock_path .parent .mkdir (parents = True , exist_ok = True )
230-
231- start_time = time .perf_counter ()
232-
233- while True :
234- # Try to acquire lock
235- fd = _acquire_lock (lock_path )
236-
237- if fd is not None :
238- # We got the lock
239- try :
240- # Double-check marker (another process may have finished)
241- if marker .exists ():
242- return False
243- # Clean partial state and initialize
244- if repo_path .exists ():
245- shutil .rmtree (repo_path )
246- init_fn ()
247- marker .touch ()
248- return True
249- finally :
250- _release_lock (lock_path , fd )
251-
252- # Lock held by another process - check if done or stale
253- if marker .exists ():
254- return False
255-
256- if _is_lock_stale (lock_path ):
257- with contextlib .suppress (OSError ):
258- lock_path .unlink ()
259- continue # Retry immediately
260-
261- # Timeout check
262- if time .perf_counter () - start_time >= timeout :
263- msg = f"Timeout waiting for { repo_path } initialization"
264- raise TimeoutError (msg )
265-
266- time .sleep (poll_interval )
267-
268-
269152def get_cache_key () -> str :
270153 """Generate cache key from VCS versions and libvcs version.
271154
@@ -805,7 +688,7 @@ def do_init() -> None:
805688 env = git_commit_envvars ,
806689 )
807690
808- _atomic_repo_init (repo_path , do_init )
691+ atomic_init (repo_path , do_init , marker_name = ".libvcs_initialized" )
809692 return repo_path
810693
811694
@@ -929,7 +812,7 @@ def svn_remote_repo(
929812 def do_init () -> None :
930813 shutil .copytree (empty_svn_repo , repo_path )
931814
932- _atomic_repo_init (repo_path , do_init )
815+ atomic_init (repo_path , do_init , marker_name = ".libvcs_initialized" )
933816 return repo_path
934817
935818
@@ -954,7 +837,7 @@ def do_init() -> None:
954837 shutil .copytree (svn_remote_repo , repo_path )
955838 svn_remote_repo_single_commit_post_init (remote_repo_path = repo_path )
956839
957- _atomic_repo_init (repo_path , do_init )
840+ atomic_init (repo_path , do_init , marker_name = ".libvcs_initialized" )
958841 return repo_path
959842
960843
@@ -1073,7 +956,7 @@ def do_init() -> None:
1073956 env = {"HGRCPATH" : str (hgconfig )},
1074957 )
1075958
1076- _atomic_repo_init (repo_path , do_init )
959+ atomic_init (repo_path , do_init , marker_name = ".libvcs_initialized" )
1077960 return repo_path
1078961
1079962
0 commit comments