Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/filelock/_soft_rw/_sync.py: 22%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

495 statements  

1"""Cross-process and cross-host reader/writer lock built on :class:`SoftFileLock` primitives.""" 

2 

3from __future__ import annotations 

4 

5import atexit 

6import hmac 

7import os 

8import re 

9import secrets 

10import socket 

11import stat 

12import sys 

13import threading 

14import time 

15import uuid 

16from contextlib import closing, contextmanager, suppress 

17from dataclasses import dataclass 

18from pathlib import Path 

19from typing import TYPE_CHECKING, Final, Literal 

20from weakref import WeakValueDictionary 

21 

22from filelock._api import ( 

23 AcquireReturnProxy, 

24 _ensure_current_process, 

25 _fork_transition, 

26 _raise_grouped_errors, 

27 _register_fork_class, 

28 _register_fork_object, 

29 _register_owned_descriptor, 

30 _unregister_owned_descriptor, 

31) 

32from filelock._error import Timeout 

33from filelock._soft import SoftFileLock 

34from filelock._util import ensure_directory_exists, touch, write_all 

35 

36if TYPE_CHECKING: 

37 from collections.abc import Callable, Generator 

38 

39 

40_Mode = Literal["read", "write"] 

41_BREAK_SUFFIX: Final[str] = ".break" 

42_MAX_MARKER_SIZE: Final[int] = 1024 

43_O_NOFOLLOW: Final[int] = getattr(os, "O_NOFOLLOW", 0) 

44_O_NONBLOCK: Final[int] = getattr(os, "O_NONBLOCK", 0) 

45# dirfd-relative I/O is a Unix-only optimization; Windows cannot ``os.open()`` a directory at all, and 

46# its ``os`` module skips dir_fd support entirely. When disabled, callers fall back to full-path ops. 

47_SUPPORTS_DIR_FD: Final[bool] = sys.platform != "win32" and os.open in os.supports_dir_fd 

48 

49_ALL_INSTANCES: Final[WeakValueDictionary[int, SoftReadWriteLock]] = WeakValueDictionary() 

50_ALL_INSTANCES_LOCK: threading.Lock = threading.Lock() 

51_SINGLETONS_UNDER_CONSTRUCTION: Final[set[Path]] = set() 

52 

53 

54class _SoftRWMeta(type): 

55 _instances: WeakValueDictionary[Path, SoftReadWriteLock] 

56 _instances_lock: threading.RLock 

57 

58 def __call__( # ruff:ignore[too-many-arguments] # forwards the public constructor's documented parameters 

59 cls, 

60 lock_file: str | os.PathLike[str], 

61 timeout: float = -1, 

62 *, 

63 blocking: bool = True, 

64 is_singleton: bool = True, 

65 heartbeat_interval: float = 30.0, 

66 stale_threshold: float | None = None, 

67 poll_interval: float = 0.25, 

68 ) -> SoftReadWriteLock: 

69 _ensure_current_process() 

70 if not is_singleton: 

71 return super().__call__( 

72 lock_file, 

73 timeout, 

74 blocking=blocking, 

75 is_singleton=is_singleton, 

76 heartbeat_interval=heartbeat_interval, 

77 stale_threshold=stale_threshold, 

78 poll_interval=poll_interval, 

79 ) 

80 

81 normalized = Path(lock_file).resolve() 

82 with cls._instances_lock: 

83 instance = cls._instances.get(normalized) 

84 if instance is None: 

85 if normalized in _SINGLETONS_UNDER_CONSTRUCTION: # pragma: needs fork 

86 msg = f"Singleton lock construction is already active for {lock_file!s}" 

87 raise RuntimeError(msg) 

88 construction_pid = os.getpid() 

89 _SINGLETONS_UNDER_CONSTRUCTION.add(normalized) 

90 try: 

91 instance = super().__call__( 

92 lock_file, 

93 timeout, 

94 blocking=blocking, 

95 is_singleton=is_singleton, 

96 heartbeat_interval=heartbeat_interval, 

97 stale_threshold=stale_threshold, 

98 poll_interval=poll_interval, 

99 ) 

100 finally: 

101 _SINGLETONS_UNDER_CONSTRUCTION.discard(normalized) 

102 if os.getpid() != construction_pid: # pragma: needs fork 

103 msg = "Lock construction cannot continue after fork; construct a new lock in the child" 

104 raise RuntimeError(msg) 

105 cls._instances[normalized] = instance 

106 elif instance.timeout != timeout or instance.blocking != blocking: 

107 msg = ( 

108 f"Singleton lock created with timeout={instance.timeout}, blocking={instance.blocking}," 

109 f" cannot be changed to timeout={timeout}, blocking={blocking}" 

110 ) 

111 raise ValueError(msg) 

112 return instance 

113 

114 

115class SoftReadWriteLock(metaclass=_SoftRWMeta): 

116 """ 

117 Cross-process and cross-host reader/writer lock built on :class:`SoftFileLock` primitives. 

118 

119 Use this class instead of :class:`~filelock.ReadWriteLock` when the lock file lives on a network 

120 filesystem (NFS, Lustre with ``-o flock``, HPC cluster shared storage). ``ReadWriteLock`` is backed 

121 by SQLite and cannot run on NFS because SQLite's ``fcntl`` locking is unreliable there. 

122 

123 Layout on disk for a lock at ``foo.lock``: 

124 

125 - ``foo.lock.state`` — a :class:`SoftFileLock` taken only during state transitions (microseconds). 

126 - ``foo.lock.write`` — writer marker; its presence means a writer is claiming or holding the lock. 

127 - ``foo.lock.readers/<host>.<pid>.<uuid>`` — one file per reader. 

128 

129 Each marker stores a random token (``secrets.token_hex(16)``), the holder's pid, and the holder's 

130 hostname. A daemon heartbeat thread refreshes ``mtime`` on every held marker. A marker whose mtime 

131 has not advanced in ``stale_threshold`` seconds may be evicted by any process on any host, giving 

132 correct behavior when a compute node crashes with a lock held. 

133 

134 Writer acquire is two-phase and writer-preferring: phase 1 claims ``.write`` (blocking any new 

135 reader), phase 2 waits for existing readers to drain. Writer starvation is impossible. 

136 

137 Reentrancy, upgrade/downgrade rules, thread pinning, and singleton caching by resolved path match 

138 :class:`~filelock.ReadWriteLock`. 

139 

140 Forking invalidates the inherited instance in the child so the child cannot double-own the lock with its parent; 

141 ``release()`` on that instance is a no-op, and the child must construct a new instance if it needs a lock. 

142 

143 Trust boundary: protects against same-UID non-cooperating processes (one host or cross-host) and 

144 same-host different-UID users via ``0o600`` / ``0o700`` permissions. Does not protect against root 

145 compromise, NTP tampering on same-UID cross-host nodes, or multi-tenant mounts where hostile 

146 co-tenants share the UID. 

147 

148 :param lock_file: path to the lock file; sidecar state/write/readers live next to it 

149 :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely 

150 :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately on contention 

151 :param is_singleton: if ``True``, reuse existing instances for the same resolved path 

152 :param heartbeat_interval: seconds between heartbeat refreshes; default 30 s 

153 :param stale_threshold: seconds of ``mtime`` inactivity before a marker is stale; defaults to 

154 ``3 * heartbeat_interval``, matching etcd's ``LeaseKeepAlive`` convention 

155 :param poll_interval: seconds between acquire retries under contention; default 0.25 s 

156 

157 .. versionadded:: 3.27.0 

158 

159 """ 

160 

161 _instances: WeakValueDictionary[Path, SoftReadWriteLock] = WeakValueDictionary() 

162 _instances_lock = threading.RLock() 

163 

164 def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option 

165 self, 

166 lock_file: str | os.PathLike[str], 

167 timeout: float = -1, 

168 *, 

169 blocking: bool = True, 

170 is_singleton: bool = True, # ruff:ignore[unused-method-argument] # consumed by _SoftRWMeta.__call__ 

171 heartbeat_interval: float = 30.0, 

172 stale_threshold: float | None = None, 

173 poll_interval: float = 0.25, 

174 ) -> None: 

175 self._creator_pid = os.getpid() 

176 if heartbeat_interval <= 0: 

177 msg = f"heartbeat_interval must be positive, got {heartbeat_interval}" 

178 raise ValueError(msg) 

179 if stale_threshold is None: 

180 stale_threshold = heartbeat_interval * 3 

181 if stale_threshold <= heartbeat_interval: 

182 msg = f"stale_threshold must exceed heartbeat_interval ({stale_threshold} <= {heartbeat_interval})" 

183 raise ValueError(msg) 

184 if poll_interval <= 0: 

185 msg = f"poll_interval must be positive, got {poll_interval}" 

186 raise ValueError(msg) 

187 

188 self.lock_file: str = os.fspath(lock_file) 

189 self.timeout: float = timeout 

190 self.blocking: bool = blocking 

191 self.heartbeat_interval: float = heartbeat_interval 

192 self.stale_threshold: float = stale_threshold 

193 self.poll_interval: float = poll_interval 

194 

195 self._paths = _Paths( 

196 state=f"{self.lock_file}.state", 

197 write=f"{self.lock_file}.write", 

198 readers=f"{self.lock_file}.readers", 

199 ) 

200 ensure_directory_exists(self.lock_file) 

201 self._locks = _Locks( 

202 internal=threading.Lock(), 

203 transaction=threading.Lock(), 

204 state=SoftFileLock(self._paths.state, timeout=-1), 

205 ) 

206 self._readers_dir_fd: int | None = None 

207 self._readers_dir_fd_token: int | None = None 

208 self._hold: _Hold | None = None 

209 self._closed: bool = False 

210 

211 with _ALL_INSTANCES_LOCK: 

212 _ALL_INSTANCES[id(self)] = self 

213 _register_fork_object(self) 

214 

215 @classmethod 

216 def _reset_class_after_fork(cls) -> None: # pragma: forked child 

217 global _ALL_INSTANCES_LOCK # ruff:ignore[global-statement] # rebinds the module lock to a fresh one in the fork child 

218 _ALL_INSTANCES_LOCK = threading.Lock() 

219 cls._instances = WeakValueDictionary() 

220 cls._instances_lock = threading.RLock() 

221 _SINGLETONS_UNDER_CONSTRUCTION.clear() 

222 

223 @contextmanager 

224 def read_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> Generator[None]: 

225 """ 

226 Context manager that acquires and releases a shared read lock. 

227 

228 Falls back to instance defaults for *timeout* and *blocking* when ``None``. 

229 

230 :param timeout: maximum wait time in seconds, or ``None`` to use the instance default 

231 :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default 

232 

233 :raises RuntimeError: if a write lock is already held on this instance 

234 :raises Timeout: if the lock cannot be acquired within *timeout* seconds 

235 

236 """ 

237 self.acquire_read(timeout, blocking=blocking) 

238 try: 

239 yield 

240 finally: 

241 self.release() 

242 

243 @contextmanager 

244 def write_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> Generator[None]: 

245 """ 

246 Context manager that acquires and releases an exclusive write lock. 

247 

248 Falls back to instance defaults for *timeout* and *blocking* when ``None``. 

249 

250 :param timeout: maximum wait time in seconds, or ``None`` to use the instance default 

251 :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default 

252 

253 :raises RuntimeError: if a read lock is already held, or a write lock is held by a different thread 

254 :raises Timeout: if the lock cannot be acquired within *timeout* seconds 

255 

256 """ 

257 self.acquire_write(timeout, blocking=blocking) 

258 try: 

259 yield 

260 finally: 

261 self.release() 

262 

263 def acquire_read(self, timeout: float | None = None, *, blocking: bool | None = None) -> AcquireReturnProxy: 

264 """ 

265 Acquire a shared read lock. 

266 

267 If this instance already holds a read lock, the lock level is incremented (reentrant). Attempting to acquire a 

268 read lock while holding a write lock raises :class:`RuntimeError` (downgrade not allowed). On the 0→1 

269 transition a daemon heartbeat thread is started that refreshes the reader marker's ``mtime`` every 

270 ``heartbeat_interval`` seconds so peers on other hosts do not evict the marker as stale. 

271 

272 :param timeout: maximum wait time in seconds, or ``None`` to use the instance default; ``-1`` means block 

273 indefinitely 

274 :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable; 

275 ``None`` uses the instance default 

276 

277 :returns: a proxy that can be used as a context manager to release the lock 

278 

279 :raises RuntimeError: if a write lock is already held on this instance, if this instance was invalidated by 

280 :func:`os.fork`, or if :meth:`close` was called 

281 :raises Timeout: if the lock cannot be acquired within *timeout* seconds 

282 

283 """ 

284 return self._acquire("read", timeout, blocking=blocking) 

285 

286 def acquire_write(self, timeout: float | None = None, *, blocking: bool | None = None) -> AcquireReturnProxy: 

287 """ 

288 Acquire an exclusive write lock. 

289 

290 If this instance already holds a write lock from the same thread, the lock level is incremented (reentrant). 

291 Attempting to acquire a write lock while holding a read lock raises :class:`RuntimeError` (upgrade not 

292 allowed). Write locks are pinned to the acquiring thread: a different thread trying to re-enter also raises 

293 :class:`RuntimeError`. 

294 

295 Writer acquisition runs in two phases. Phase 1 atomically claims ``<path>.write`` via ``O_CREAT | O_EXCL``, 

296 which immediately blocks any new reader on any host. Phase 2 waits for existing readers to drain. Writer 

297 starvation is impossible: new readers see ``<path>.write`` during phase 2 and wait behind the pending writer. 

298 

299 :param timeout: maximum wait time in seconds, or ``None`` to use the instance default; ``-1`` means block 

300 indefinitely 

301 :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable; 

302 ``None`` uses the instance default 

303 

304 :returns: a proxy that can be used as a context manager to release the lock 

305 

306 :raises RuntimeError: if a read lock is already held, if a write lock is held by a different thread, if this 

307 instance was invalidated by :func:`os.fork`, or if :meth:`close` was called 

308 :raises Timeout: if the lock cannot be acquired within *timeout* seconds 

309 

310 """ 

311 return self._acquire("write", timeout, blocking=blocking) 

312 

313 @classmethod 

314 def get_lock( 

315 cls, 

316 lock_file: str | os.PathLike[str], 

317 timeout: float = -1, 

318 *, 

319 blocking: bool = True, 

320 ) -> SoftReadWriteLock: 

321 """ 

322 Return the singleton :class:`SoftReadWriteLock` for *lock_file*. 

323 

324 :param lock_file: path to the lock file; sidecar state/write/readers live next to it 

325 :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely 

326 :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable 

327 

328 :returns: the singleton lock instance 

329 

330 :raises ValueError: if an instance already exists for this path with different *timeout* or *blocking* values 

331 

332 """ 

333 return cls(lock_file, timeout, blocking=blocking) 

334 

335 def close(self) -> None: 

336 """ 

337 Release any held lock and release internal filesystem resources. 

338 

339 Idempotent. After calling this method the instance can no longer acquire locks — subsequent acquires raise 

340 :class:`RuntimeError`. A fork-invalidated instance is closed without raising. 

341 """ 

342 if self._creator_pid != os.getpid(): # pragma: forked child 

343 return 

344 self.release(force=True) 

345 with self._locks.internal: 

346 if self._closed: 

347 return 

348 self._closed = True 

349 if self._readers_dir_fd is not None: # pragma: needs dir-fd 

350 with _fork_transition(): 

351 if self._readers_dir_fd_token is not None: # pragma: needs dir-fd 

352 _unregister_owned_descriptor(self._readers_dir_fd_token) 

353 self._readers_dir_fd_token = None 

354 fd, self._readers_dir_fd = self._readers_dir_fd, None 

355 with suppress(OSError): # pragma: needs dir-fd 

356 os.close(fd) 

357 

358 def release(self, *, force: bool = False) -> None: 

359 """ 

360 Release one level of the current lock. 

361 

362 When the lock level reaches zero the heartbeat thread is stopped and the held marker file is unlinked. On a 

363 fork-invalidated instance (that is, the child of a :func:`os.fork` call made while the parent held a lock) 

364 this method is a no-op so inherited ``with`` blocks can unwind cleanly in the child. 

365 

366 :param force: if ``True``, release the lock completely regardless of the current lock level 

367 

368 :raises RuntimeError: if no lock is currently held and *force* is ``False`` 

369 

370 """ 

371 if self._creator_pid != os.getpid(): # pragma: forked child 

372 return 

373 with self._locks.internal: 

374 hold = self._hold 

375 if hold is None: 

376 if force: 

377 return 

378 msg = f"Cannot release a lock on {self.lock_file} (lock id: {id(self)}) that is not held" 

379 raise RuntimeError(msg) 

380 if force: 

381 hold.level = 0 

382 else: 

383 hold.level -= 1 

384 if hold.level > 0: 

385 return 

386 self._hold = None 

387 

388 # Order matters: signal → join → unlink. A late tick on a deleted marker is harmless and the 

389 # heartbeat's token check would catch a re-acquisition race, but joining first removes that race. 

390 hold.heartbeat_stop.set() 

391 hold.heartbeat_thread.join(timeout=self.heartbeat_interval + 1.0) 

392 if hold.is_reader: 

393 _unlink(hold.marker_name, dir_fd=self._readers_dir_fd) 

394 else: 

395 self._unlink_writer_marker_if_ours(hold.token) 

396 

397 def _unlink_writer_marker_if_ours(self, token: str) -> None: 

398 # Remove the writer marker only while it still carries our token. If this holder was paused long 

399 # enough (a stop-the-world GC pause, SIGSTOP, a suspended VM) for a peer to evict the marker as 

400 # stale and claim the writer slot itself, the file now at <path>.write is the peer's live marker; 

401 # unlinking it by path would let a second writer through and break mutual exclusion. The state lock 

402 # serializes this against a concurrent break/claim, and the heartbeat is already stopped, so the 

403 # token we read is authoritative. Mirrors the token re-check the stale-break path already does. 

404 with self._locks.state: 

405 if (read := _read_marker(self._paths.write)) is None: 

406 return 

407 info, _ = read 

408 if info is None or not hmac.compare_digest(info.token, token): 

409 return 

410 _unlink(self._paths.write) 

411 

412 def _acquire( 

413 self, 

414 mode: _Mode, 

415 timeout: float | None, 

416 *, 

417 blocking: bool | None, 

418 ) -> AcquireReturnProxy: 

419 if self._creator_pid != os.getpid(): # pragma: forked child 

420 msg = f"SoftReadWriteLock on {self.lock_file} was invalidated by fork(); construct a new instance" 

421 raise RuntimeError(msg) 

422 timeout = self.timeout if timeout is None else timeout 

423 blocking = self.blocking if blocking is None else blocking 

424 

425 with self._locks.internal: 

426 if self._closed: 

427 msg = f"SoftReadWriteLock on {self.lock_file} has been closed" 

428 raise RuntimeError(msg) 

429 if self._hold is not None: 

430 return self._validate_reentrant(mode) 

431 

432 start = time.perf_counter() 

433 if not blocking: 

434 acquired = self._locks.transaction.acquire(blocking=False) 

435 elif timeout == -1: 

436 acquired = self._locks.transaction.acquire(blocking=True) 

437 else: 

438 acquired = self._locks.transaction.acquire(blocking=True, timeout=timeout) 

439 if not acquired: 

440 raise Timeout(self.lock_file) from None 

441 try: 

442 return self._do_acquire_inner(mode, timeout, start, blocking=blocking) 

443 finally: 

444 self._locks.transaction.release() 

445 

446 def _do_acquire_inner( 

447 self, 

448 mode: _Mode, 

449 effective_timeout: float, 

450 start: float, 

451 *, 

452 blocking: bool, 

453 ) -> AcquireReturnProxy: 

454 with self._locks.internal: 

455 if self._hold is not None: 

456 return self._validate_reentrant(mode) 

457 deadline = None if effective_timeout == -1 else start + effective_timeout 

458 token = secrets.token_hex(16) 

459 if mode == "write": 

460 marker_name, is_reader = self._acquire_writer_slot(token, deadline=deadline, blocking=blocking) 

461 else: 

462 marker_name, is_reader = self._acquire_reader_slot(token, deadline=deadline, blocking=blocking) 

463 stop_event = threading.Event() 

464 heartbeat = _HeartbeatThread( 

465 refresh=self._refresh_marker, 

466 interval=self.heartbeat_interval, 

467 stop_event=stop_event, 

468 name=f"filelock-heartbeat-{id(self):x}", 

469 ) 

470 # Publish the hold and start its heartbeat under one internal-lock section, so a concurrent release() never 

471 # observes a hold whose thread has not started and joins it. If the OS refuses the thread, clear the hold and 

472 # unlink the marker we claimed: left in place, a peer evicts it as stale and acquires while this instance still 

473 # believes it holds the lock. 

474 start_error: BaseException | None = None 

475 with self._locks.internal: 

476 self._hold = _Hold( 

477 level=1, 

478 mode=mode, 

479 write_thread_id=threading.get_ident() if mode == "write" else None, 

480 marker_name=marker_name, 

481 is_reader=is_reader, 

482 token=token, 

483 heartbeat_thread=heartbeat, 

484 heartbeat_stop=stop_event, 

485 ) 

486 try: 

487 heartbeat.start() 

488 except BaseException as error: # ruff:ignore[blind-except] # clear the slot below and re-raise 

489 self._hold = None 

490 start_error = error 

491 if start_error is not None: 

492 if is_reader: 

493 _unlink(marker_name, dir_fd=self._readers_dir_fd) 

494 else: 

495 self._unlink_writer_marker_if_ours(token) 

496 raise start_error 

497 return AcquireReturnProxy(lock=self) 

498 

499 def _validate_reentrant(self, mode: _Mode) -> AcquireReturnProxy: 

500 hold = self._hold 

501 assert hold is not None # ruff:ignore[assert] # callers dispatch here only inside the self._hold is not None branch 

502 if hold.mode != mode: 

503 opposite = "write" if mode == "read" else "read" 

504 direction = "downgrade" if mode == "read" else "upgrade" 

505 msg = ( 

506 f"Cannot acquire {mode} lock on {self.lock_file} (lock id: {id(self)}): " 

507 f"already holding a {opposite} lock ({direction} not allowed)" 

508 ) 

509 raise RuntimeError(msg) 

510 if mode == "write" and (cur := threading.get_ident()) != hold.write_thread_id: 

511 msg = ( 

512 f"Cannot acquire write lock on {self.lock_file} (lock id: {id(self)}) " 

513 f"from thread {cur} while it is held by thread {hold.write_thread_id}" 

514 ) 

515 raise RuntimeError(msg) 

516 hold.level += 1 

517 return AcquireReturnProxy(lock=self) 

518 

519 def _acquire_writer_slot( 

520 self, 

521 token: str, 

522 *, 

523 deadline: float | None, 

524 blocking: bool, 

525 ) -> tuple[str, bool]: 

526 # Phase 2 scans readers/ via dirfd (where supported), so we need it open even though writers never 

527 # create files inside. 

528 self._open_readers_dir() 

529 

530 def try_claim_writer() -> bool: 

531 with self._locks.state: 

532 return self._claim_writer_marker(token) 

533 

534 def readers_drained_touching() -> bool: 

535 with self._locks.state: 

536 # A peer may replace an expired marker while this process pauses. Refresh only our token; touching a 

537 # successor's marker would let this acquisition proceed without owning the writer slot. 

538 if not self._touch_writer_marker_if_ours(token) and not self._claim_writer_marker(token): 

539 return False 

540 self._break_stale_readers(time.time()) 

541 return not self._any_readers() 

542 

543 self._wait_for(try_claim_writer, deadline=deadline, blocking=blocking) 

544 try: 

545 self._wait_for(readers_drained_touching, deadline=deadline, blocking=blocking) 

546 except Timeout: 

547 # Give up our writer claim so readers can make progress again, but only while the marker is 

548 # still ours: a peer may have evicted it as stale and claimed the slot while phase 2 waited. 

549 self._unlink_writer_marker_if_ours(token) 

550 raise 

551 return self._paths.write, False 

552 

553 def _claim_writer_marker(self, token: str) -> bool: 

554 # Claim the writer slot for ``token``. Must be called holding ``self._locks.state``. Evicts a 

555 # stale marker first, then refuses to claim while a live ``.write`` exists so a peer holding the 

556 # slot is waited out instead of overwritten. 

557 _break_stale_marker(self._paths.write, stale_threshold=self.stale_threshold, now=time.time()) 

558 if _file_exists(self._paths.write): 

559 return False 

560 try: 

561 _atomic_create_marker(self._paths.write, token) 

562 except FileExistsError: 

563 return False 

564 return True 

565 

566 def _touch_writer_marker_if_ours(self, token: str) -> bool: 

567 # Refresh the writer marker through a single O_NOFOLLOW fd, but only while it still carries our 

568 # token. Returns False when the marker is gone or now belongs to a peer that reclaimed the slot, 

569 # so the caller can re-claim rather than keep a stranger's marker alive. Mirrors _refresh_marker. 

570 fd = _open_marker(self._paths.write) 

571 if fd is None: 

572 return False 

573 try: 

574 try: 

575 data = os.read(fd, _MAX_MARKER_SIZE + 1) 

576 except OSError: # pragma: no cover - e.g. EAGAIN from a hostile FIFO that has a writer attached 

577 return False 

578 info = _parse_marker_bytes(data) 

579 if info is None or not hmac.compare_digest(info.token, token): 

580 return False 

581 with suppress(OSError): 

582 touch(self._paths.write, fd=fd) 

583 return True 

584 finally: 

585 os.close(fd) 

586 

587 def _acquire_reader_slot( 

588 self, 

589 token: str, 

590 *, 

591 deadline: float | None, 

592 blocking: bool, 

593 ) -> tuple[str, bool]: 

594 self._open_readers_dir() 

595 reader_name = f"{uuid.uuid4().hex}.{os.getpid()}" 

596 dir_fd = self._readers_dir_fd 

597 full_reader_path = str(Path(self._paths.readers) / reader_name) 

598 

599 def try_claim_reader() -> bool: 

600 with self._locks.state: 

601 _break_stale_marker(self._paths.write, stale_threshold=self.stale_threshold, now=time.time()) 

602 if _file_exists(self._paths.write): 

603 return False 

604 if dir_fd is not None: # pragma: needs dir-fd 

605 _atomic_create_marker(reader_name, token, dir_fd=dir_fd) 

606 else: # pragma: win32 cover 

607 _atomic_create_marker(full_reader_path, token) 

608 return True 

609 

610 self._wait_for(try_claim_reader, deadline=deadline, blocking=blocking) 

611 return (reader_name if dir_fd is not None else full_reader_path), True 

612 

613 def _wait_for( 

614 self, 

615 predicate: Callable[[], bool], 

616 *, 

617 deadline: float | None, 

618 blocking: bool, 

619 ) -> None: 

620 while True: 

621 if predicate(): 

622 return 

623 now = time.perf_counter() 

624 if not blocking: 

625 raise Timeout(self.lock_file) 

626 if deadline is not None and now >= deadline: 

627 raise Timeout(self.lock_file) 

628 sleep_for = self.poll_interval 

629 if deadline is not None: 

630 sleep_for = min(sleep_for, max(deadline - now, 0.0)) 

631 time.sleep(sleep_for) 

632 

633 def _open_readers_dir(self) -> None: 

634 readers_path = Path(self._paths.readers) 

635 with suppress(FileExistsError): 

636 readers_path.mkdir(mode=0o700) 

637 # mkdir has no O_NOFOLLOW, so verify via lstat that we did not land on an attacker-placed symlink 

638 # or a regular file before we open or scan inside. 

639 st = os.lstat(self._paths.readers) 

640 if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode): 

641 msg = f"{self._paths.readers} exists but is not a directory or is a symlink; refusing to use it" 

642 raise RuntimeError(msg) 

643 if self._readers_dir_fd is None and _SUPPORTS_DIR_FD: # pragma: needs dir-fd 

644 with _fork_transition(): 

645 fd = os.open(self._paths.readers, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | _O_NOFOLLOW) 

646 try: 

647 token = _register_owned_descriptor(fd) 

648 except BaseException as registration_error: 

649 try: 

650 os.close(fd) 

651 except BaseException as close_error: # ruff:ignore[blind-except] # both errors surface via the group below 

652 _raise_grouped_errors( 

653 "reader directory registration and descriptor close both failed", 

654 registration_error, 

655 close_error, 

656 ) 

657 raise 

658 self._readers_dir_fd = fd 

659 self._readers_dir_fd_token = token 

660 

661 def _any_readers(self) -> bool: 

662 with closing(self._iter_reader_entries()) as entries: 

663 for _ in entries: 

664 return True 

665 return False 

666 

667 def _iter_reader_entries(self) -> Generator[tuple[str, bool]]: 

668 """ 

669 Yield ``(name, dirfd_relative)`` pairs for every live reader marker. 

670 

671 ``dirfd_relative`` is ``True`` when *name* should be passed to ``dir_fd=``-aware syscalls; ``False`` 

672 when *name* is a full path because dirfd-relative I/O is unavailable on this platform. 

673 

674 A consumer that stops early must close this generator: while suspended it holds the ``scandir`` handle open, 

675 and leaving that to the collector surfaces as an unraisable exception inside whatever runs next. 

676 """ 

677 if self._readers_dir_fd is not None: # pragma: needs dir-fd 

678 with os.scandir(self._readers_dir_fd) as it: 

679 for entry in it: 

680 if not _is_housekeeping_name(entry.name): 

681 yield entry.name, True 

682 return 

683 readers_path = Path(self._paths.readers) # pragma: win32 cover 

684 with os.scandir(readers_path) as it: # pragma: win32 cover 

685 for entry in it: # pragma: win32 cover 

686 if not _is_housekeeping_name(entry.name): # pragma: win32 cover 

687 yield str(readers_path / entry.name), False # pragma: win32 cover 

688 

689 def _break_stale_readers(self, now: float) -> None: 

690 names: list[tuple[str, int | None]] = [] 

691 try: 

692 with closing(self._iter_reader_entries()) as entries: 

693 for name, dirfd_relative in entries: 

694 names.append((name, self._readers_dir_fd if dirfd_relative else None)) 

695 except OSError: # pragma: no cover - transient NFS scandir hiccup 

696 return 

697 for name, fd in names: 

698 _break_stale_marker(name, stale_threshold=self.stale_threshold, now=now, dir_fd=fd) 

699 

700 def _refresh_marker(self) -> bool: 

701 with self._locks.internal: 

702 hold = self._hold 

703 if hold is None: # pragma: no cover - race between stop_event.set and join 

704 return False 

705 marker_name = hold.marker_name 

706 token = hold.token 

707 dir_fd = self._readers_dir_fd if hold.is_reader else None 

708 

709 # Open once with O_NOFOLLOW and touch that exact descriptor. Refreshing through the verified fd 

710 # (instead of re-opening by name) closes the window where a peer unlinks our marker and drops a symlink 

711 # or a different file at the path between the read and the touch: utime then lands on the inode we 

712 # verified, or nowhere. Only an unambiguous loss stops the heartbeat: the marker gone, or a peer's token 

713 # in its place. A transient filesystem error (ESTALE / EIO on the NFS-style filesystems this lock targets) 

714 # keeps the heartbeat alive to retry next tick, the way the touch below already does, so one blip does not 

715 # silently drop a held lock. 

716 try: 

717 fd = _open_marker_fd(marker_name, dir_fd=dir_fd) 

718 except FileNotFoundError: 

719 return False 

720 except OSError: 

721 return True 

722 try: 

723 try: 

724 data = _read_marker_fd(fd) 

725 except OSError: # a transient read error or EAGAIN from a hostile FIFO; retry rather than drop the lock 

726 return True 

727 info = _parse_marker_bytes(data) 

728 # Token mismatch means another process already evicted our marker and created its own; stop the 

729 # thread so it does not keep a stranger's file alive. 

730 if info is None or not hmac.compare_digest(info.token, token): 

731 return False 

732 # A transient touch failure (ESTALE / EIO on the NFS-style filesystems this lock targets) must not 

733 # kill the heartbeat thread: the read above just confirmed the marker is still ours, so swallow the 

734 # error and retry on the next tick rather than letting the lease lapse while we still hold the lock. 

735 with suppress(OSError): 

736 touch(marker_name, fd=fd) 

737 return True 

738 finally: 

739 os.close(fd) 

740 

741 def _reset_after_fork_in_child(self) -> None: # pragma: forked child 

742 self._locks = _Locks( 

743 internal=threading.Lock(), 

744 transaction=threading.Lock(), 

745 state=self._locks.state, 

746 ) 

747 self._hold = None 

748 self._readers_dir_fd = None 

749 self._readers_dir_fd_token = None 

750 

751 

752class _HeartbeatThread(threading.Thread): 

753 def __init__( 

754 self, 

755 refresh: Callable[[], bool], 

756 interval: float, 

757 stop_event: threading.Event, 

758 name: str, 

759 ) -> None: 

760 super().__init__(name=name, daemon=True) 

761 self._refresh = refresh 

762 self._interval = interval 

763 self._stop_event = stop_event 

764 

765 def run(self) -> None: 

766 while not self._stop_event.wait(self._interval): 

767 if not self._refresh(): 

768 self._stop_event.set() 

769 return 

770 

771 

772def _read_marker(name: str, *, dir_fd: int | None = None) -> tuple[_MarkerInfo | None, float] | None: 

773 fd = _open_marker(name, dir_fd=dir_fd) 

774 if fd is None: 

775 return None 

776 try: 

777 st = os.fstat(fd) 

778 # A legitimate marker is a regular file, so anything else at the path (a FIFO, say) is reported as a 

779 # malformed marker (its mtime still drives stale eviction) without being read. Reading is where 

780 # platforms diverge: an empty non-blocking read yields 0 bytes on Linux/macOS but EAGAIN on FreeBSD, 

781 # and the EAGAIN used to abort the stale-break and wedge the acquire until timeout (#587). 

782 if not stat.S_ISREG(st.st_mode): # pragma: needs fifo 

783 return None, st.st_mtime 

784 data = os.read(fd, _MAX_MARKER_SIZE + 1) 

785 except OSError: # pragma: no cover - marker vanished or turned unreadable between open and read 

786 return None 

787 finally: 

788 os.close(fd) 

789 return _parse_marker_bytes(data), st.st_mtime 

790 

791 

792def _read_marker_fd(fd: int) -> bytes: 

793 return os.read(fd, _MAX_MARKER_SIZE + 1) 

794 

795 

796def _open_marker_fd(name: str, *, dir_fd: int | None = None) -> int: 

797 # The file is ours; these guard a hostile mid-flight swap. O_NOFOLLOW rejects a symlink; O_NONBLOCK keeps 

798 # a real FIFO from blocking the open forever, so it reads as a malformed marker instead of wedging a peer 

799 # that holds the state lock. 

800 flags = os.O_RDONLY | _O_NOFOLLOW | _O_NONBLOCK 

801 return os.open(name, flags, dir_fd=dir_fd) if _SUPPORTS_DIR_FD and dir_fd is not None else os.open(name, flags) 

802 

803 

804def _open_marker(name: str, *, dir_fd: int | None = None) -> int | None: 

805 try: 

806 return _open_marker_fd(name, dir_fd=dir_fd) 

807 except OSError: 

808 return None 

809 

810 

811def _parse_marker_bytes(data: bytes) -> _MarkerInfo | None: 

812 # Trust nothing about attacker-controlled markers; any deviation returns None so callers fall through 

813 # to stale cleanup. ``re.match`` caches compiled patterns internally, so the regex is built only once 

814 # despite being defined inline. 

815 if not data or len(data) > _MAX_MARKER_SIZE: 

816 return None 

817 try: 

818 text = data.decode("ascii") 

819 except UnicodeDecodeError: 

820 return None 

821 match = re.match( 

822 r""" 

823 \A # start of string 

824 (?P<token> [0-9a-f]{32} ) \n # 128-bit hex token 

825 (?P<pid> [1-9][0-9]{0,9} ) \n # decimal pid: no leading zero, ≤ 10 digits 

826 (?P<hostname> [\x21-\x7e]{1,253}) # printable non-whitespace ASCII (RFC 1123 hostname limit) 

827 \n* # tolerate sloppy writers that append extra newlines 

828 \Z # end of string 

829 """, 

830 text, 

831 re.VERBOSE, 

832 ) 

833 if match is None: 

834 return None 

835 pid = int(match["pid"], 10) 

836 if pid > 2**31 - 1: 

837 return None 

838 return _MarkerInfo(token=match["token"], pid=pid, hostname=match["hostname"]) 

839 

840 

841def _unlink(name: str, *, dir_fd: int | None = None) -> None: 

842 with suppress(FileNotFoundError): 

843 if _SUPPORTS_DIR_FD and dir_fd is not None: # pragma: needs dir-fd 

844 # Path.unlink has no dir_fd support, so we stay on os.unlink for the dirfd path. 

845 os.unlink(name, dir_fd=dir_fd) 

846 else: 

847 Path(name).unlink() 

848 

849 

850def _break_stale_marker( # ruff:ignore[too-many-return-statements] # each return is a distinct abort/commit point in the break protocol 

851 name: str, 

852 *, 

853 stale_threshold: float, 

854 now: float, 

855 dir_fd: int | None = None, 

856) -> bool: 

857 # Atomic break pattern: read → rename to unique break-name → re-verify → unlink. The rename gives us a 

858 # private name nobody else can touch; if the re-verify sees a newer mtime or a different token, the 

859 # legitimate holder's heartbeat fired between read and rename and we must abort (leaving the .break.* 

860 # file behind rather than rollback-renaming, because rollback is itself racy). 

861 if (read_result := _read_marker(name, dir_fd=dir_fd)) is None: 

862 return False 

863 info_before, mtime_before = read_result 

864 if now - mtime_before <= stale_threshold: 

865 return False 

866 if info_before is None: 

867 _unlink(name, dir_fd=dir_fd) 

868 return True 

869 

870 break_name = f"{name}{_BREAK_SUFFIX}.{os.getpid()}.{secrets.token_hex(16)}" 

871 try: 

872 if _SUPPORTS_DIR_FD and dir_fd is not None: # pragma: needs dir-fd 

873 os.rename(name, break_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd) 

874 else: 

875 Path(name).rename(break_name) 

876 except OSError: # pragma: no cover - race where the marker vanishes between read and rename 

877 return False 

878 

879 read_after = _read_marker(break_name, dir_fd=dir_fd) 

880 if read_after is None: # pragma: no cover - race where a peer unlinks the break-name file 

881 return False 

882 info_after, mtime_after = read_after 

883 if info_after is None: # pragma: no cover - content replaced post-rename by a racing peer 

884 _unlink(break_name, dir_fd=dir_fd) 

885 return True 

886 if not hmac.compare_digest(info_before.token, info_after.token): # pragma: no cover - race only 

887 return False 

888 if mtime_after > mtime_before: # pragma: no cover - heartbeat raced our rename 

889 return False 

890 _unlink(break_name, dir_fd=dir_fd) 

891 return True 

892 

893 

894def _atomic_create_marker(name: str, token: str, *, dir_fd: int | None = None) -> None: 

895 # O_NOFOLLOW blocks the symlink-overwrite attack where an attacker pre-creates the marker path as a 

896 # symlink pointing at a victim file. Mode 0o600 keeps the token unreadable to other users. 

897 flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | _O_NOFOLLOW 

898 if _SUPPORTS_DIR_FD and dir_fd is not None: # pragma: needs dir-fd 

899 fd = os.open(name, flags, 0o600, dir_fd=dir_fd) 

900 else: 

901 fd = os.open(name, flags, 0o600) 

902 # Write the whole record before the marker counts as created. On failure remove it only while the path still names 

903 # the file we opened, so a rollback never deletes a marker a concurrent reader recreated at this name. 

904 identity: tuple[int, int] | None = None 

905 try: 

906 st = os.fstat(fd) 

907 identity = st.st_dev, st.st_ino 

908 write_all(fd, f"{token}\n{os.getpid()}\n{socket.gethostname()}\n".encode("ascii")) 

909 except BaseException: 

910 os.close(fd) 

911 if identity is not None and _same_file(name, identity, dir_fd=dir_fd): 

912 _unlink(name, dir_fd=dir_fd) 

913 raise 

914 else: 

915 os.close(fd) 

916 

917 

918def _same_file(name: str, identity: tuple[int, int], *, dir_fd: int | None) -> bool: 

919 try: 

920 st = os.lstat(name, dir_fd=dir_fd) if _SUPPORTS_DIR_FD and dir_fd is not None else os.lstat(name) 

921 except OSError: 

922 return False 

923 return (st.st_dev, st.st_ino) == identity 

924 

925 

926def _file_exists(path: str) -> bool: 

927 try: 

928 st = os.lstat(path) 

929 except FileNotFoundError: 

930 return False 

931 return stat.S_ISREG(st.st_mode) 

932 

933 

934def _is_housekeeping_name(name: str) -> bool: 

935 return name.startswith(".") or _BREAK_SUFFIX in name 

936 

937 

938@dataclass(frozen=True) 

939class _Paths: 

940 state: str 

941 write: str 

942 readers: str 

943 

944 

945@dataclass 

946class _Locks: 

947 internal: threading.Lock 

948 transaction: threading.Lock 

949 state: SoftFileLock 

950 

951 

952@dataclass(frozen=True) 

953class _MarkerInfo: 

954 token: str 

955 pid: int 

956 hostname: str 

957 

958 

959@dataclass 

960class _Hold: 

961 """Everything that exists only while a lock is held; ``None`` when the instance has no lock.""" 

962 

963 level: int 

964 mode: _Mode 

965 write_thread_id: int | None 

966 marker_name: str 

967 is_reader: bool 

968 token: str 

969 heartbeat_thread: _HeartbeatThread 

970 heartbeat_stop: threading.Event 

971 

972 

973def _cleanup_all_instances() -> None: # pragma: no cover - runs from atexit at interpreter shutdown 

974 for instance in list(_ALL_INSTANCES.values()): 

975 with suppress(Exception): 

976 instance.release(force=True) 

977 

978 

979atexit.register(_cleanup_all_instances) 

980_register_fork_class(SoftReadWriteLock) 

981 

982 

983__all__ = [ 

984 "SoftReadWriteLock", 

985]