22
33Prevents concurrent processes from writing to the same workspace
44(e.g. two training sweeps or a sync + writeup running in parallel).
5- Uses POSIX fcntl advisory locking; on platforms without fcntl support,
6- locking is unavailable and callers will receive a RuntimeError.
5+ Uses POSIX ``flock`` or the Windows CRT byte-range lock.
76"""
87
98from __future__ import annotations
1312from collections .abc import Iterator
1413from contextlib import contextmanager
1514from pathlib import Path
16- from typing import TextIO
15+ from typing import TextIO , TypedDict
1716
1817try :
1918 import fcntl
20- except Exception as exc :
19+ except ImportError :
2120 fcntl = None # type: ignore[assignment]
22- _FCNTL_IMPORT_ERROR = exc
23- else :
24- _FCNTL_IMPORT_ERROR = None
21+
22+ try :
23+ import msvcrt
24+ except ImportError :
25+ msvcrt = None # type: ignore[assignment]
2526
2627
2728_REENTRANT_GUARD = threading .RLock ()
28- _REENTRANT_LOCKS : dict [str , dict [str , object ]] = {}
29+
30+
31+ class _HeldLock (TypedDict ):
32+ fp : TextIO
33+ count : int
34+
35+
36+ _REENTRANT_LOCKS : dict [str , _HeldLock ] = {}
2937
3038
3139class RunLockTimeoutError (TimeoutError ):
3240 """Raised when the workspace lock cannot be acquired within timeout."""
3341
3442
43+ def _lock_nonblocking (lock_fp : TextIO ) -> None :
44+ lock_fp .seek (0 )
45+ if fcntl is not None :
46+ fcntl .flock (lock_fp .fileno (), fcntl .LOCK_EX | fcntl .LOCK_NB )
47+ return
48+ if msvcrt is not None :
49+ msvcrt .locking (lock_fp .fileno (), msvcrt .LK_NBLCK , 1 )
50+ return
51+ raise RuntimeError ("no supported process lock implementation is available" )
52+
53+
54+ def _unlock (lock_fp : TextIO ) -> None :
55+ lock_fp .seek (0 )
56+ if fcntl is not None :
57+ fcntl .flock (lock_fp .fileno (), fcntl .LOCK_UN )
58+ elif msvcrt is not None :
59+ msvcrt .locking (lock_fp .fileno (), msvcrt .LK_UNLCK , 1 )
60+
61+
62+ def _is_lock_contention (exc : OSError ) -> bool :
63+ if isinstance (exc , BlockingIOError ):
64+ return True
65+ return msvcrt is not None and exc .errno in {13 , 33 , 36 }
66+
67+
3568def acquire_run_lock (
3669 run_dir : Path ,
3770 timeout : int = 30 ,
@@ -43,8 +76,8 @@ def acquire_run_lock(
4376 The caller must keep the returned file handle alive until the
4477 protected operation completes; closing the handle releases the lock.
4578 """
46- if fcntl is None :
47- raise RuntimeError (f"fcntl unavailable on this platform: { _FCNTL_IMPORT_ERROR } " )
79+ if fcntl is None and msvcrt is None :
80+ raise RuntimeError ("no supported process lock implementation is available " )
4881
4982 run_dir = run_dir .resolve ()
5083 run_dir .mkdir (parents = True , exist_ok = True )
@@ -54,23 +87,29 @@ def acquire_run_lock(
5487 existing = _REENTRANT_LOCKS .get (str (lock_path ))
5588 if existing is not None :
5689 existing ["count" ] = int (existing ["count" ]) + 1
57- return existing ["fp" ] # type: ignore[return-value]
90+ return existing ["fp" ]
5891
5992 lock_path .touch (exist_ok = True )
60- fp = lock_path .open ("w" , encoding = "utf-8" )
93+ fp = lock_path .open ("a+" , encoding = "utf-8" )
94+ if lock_path .stat ().st_size == 0 :
95+ fp .write ("\0 " )
96+ fp .flush ()
6197
6298 start = time .monotonic ()
6399 waiting_printed = False
64100 while True :
65101 try :
66- fcntl . flock (fp . fileno (), fcntl . LOCK_EX | fcntl . LOCK_NB )
102+ _lock_nonblocking (fp )
67103 with _REENTRANT_GUARD :
68104 _REENTRANT_LOCKS [str (lock_path )] = {
69105 "fp" : fp ,
70106 "count" : 1 ,
71107 }
72108 return fp
73- except BlockingIOError :
109+ except OSError as exc :
110+ if not _is_lock_contention (exc ):
111+ fp .close ()
112+ raise
74113 elapsed = time .monotonic () - start
75114 if timeout >= 0 and elapsed >= float (timeout ):
76115 fp .close ()
@@ -84,8 +123,6 @@ def acquire_run_lock(
84123
85124
86125def release_run_lock (lock_fp : TextIO ) -> None :
87- if fcntl is None :
88- return
89126 lock_path = Path (getattr (lock_fp , "name" , "" )).resolve ()
90127 with _REENTRANT_GUARD :
91128 existing = _REENTRANT_LOCKS .get (str (lock_path ))
@@ -96,7 +133,7 @@ def release_run_lock(lock_fp: TextIO) -> None:
96133 return
97134 _REENTRANT_LOCKS .pop (str (lock_path ), None )
98135 try :
99- fcntl . flock (lock_fp . fileno (), fcntl . LOCK_UN )
136+ _unlock (lock_fp )
100137 finally :
101138 lock_fp .close ()
102139
0 commit comments