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

503 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 stat 

11import sys 

12import threading 

13import time 

14import uuid 

15from contextlib import closing, contextmanager, suppress 

16from dataclasses import dataclass 

17from math import isfinite 

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._identity import host_name 

34from filelock._soft import SoftFileLock 

35from filelock._util import ensure_directory_exists, touch, write_all 

36 

37if TYPE_CHECKING: 

38 from collections.abc import Callable, Generator 

39 

40 

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

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

43_MAX_MARKER_SIZE: Final[int] = 1024 

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

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

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

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

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

49 

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

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

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

53 

54 

55class _SoftRWMeta(type): 

56 _instances: WeakValueDictionary[Path, SoftReadWriteLock] 

57 _instances_lock: threading.RLock 

58 

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

60 cls, 

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

62 timeout: float = -1, 

63 *, 

64 blocking: bool = True, 

65 is_singleton: bool = True, 

66 heartbeat_interval: float = 30.0, 

67 stale_threshold: float | None = None, 

68 poll_interval: float = 0.25, 

69 ) -> SoftReadWriteLock: 

70 _ensure_current_process() 

71 if not is_singleton: 

72 return super().__call__( 

73 lock_file, 

74 timeout, 

75 blocking=blocking, 

76 is_singleton=is_singleton, 

77 heartbeat_interval=heartbeat_interval, 

78 stale_threshold=stale_threshold, 

79 poll_interval=poll_interval, 

80 ) 

81 

82 normalized = Path(lock_file).resolve() 

83 with cls._instances_lock: 

84 instance = cls._instances.get(normalized) 

85 if instance is None: 

86 if normalized in _SINGLETONS_UNDER_CONSTRUCTION: # pragma: needs fork 

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

88 raise RuntimeError(msg) 

89 construction_pid = os.getpid() 

90 _SINGLETONS_UNDER_CONSTRUCTION.add(normalized) 

91 try: 

92 instance = super().__call__( 

93 lock_file, 

94 timeout, 

95 blocking=blocking, 

96 is_singleton=is_singleton, 

97 heartbeat_interval=heartbeat_interval, 

98 stale_threshold=stale_threshold, 

99 poll_interval=poll_interval, 

100 ) 

101 finally: 

102 _SINGLETONS_UNDER_CONSTRUCTION.discard(normalized) 

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

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

105 raise RuntimeError(msg) 

106 cls._instances[normalized] = instance 

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

108 msg = ( 

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

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

111 ) 

112 raise ValueError(msg) 

113 else: 

114 _validate_intervals(heartbeat_interval, stale_threshold, poll_interval) 

115 return instance 

116 

117 

118class SoftReadWriteLock(metaclass=_SoftRWMeta): 

119 """ 

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

121 

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

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

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

125 

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

127 

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

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

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

131 

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

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

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

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

136 

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

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

139 

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

141 :class:`~filelock.ReadWriteLock`. 

142 

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

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

145 

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

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

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

149 co-tenants share the UID. 

150 

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

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

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

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

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

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

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

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

159 

160 .. versionadded:: 3.27.0 

161 

162 """ 

163 

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

165 _instances_lock = threading.RLock() 

166 

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

168 self, 

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

170 timeout: float = -1, 

171 *, 

172 blocking: bool = True, 

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

174 heartbeat_interval: float = 30.0, 

175 stale_threshold: float | None = None, 

176 poll_interval: float = 0.25, 

177 ) -> None: 

178 self._creator_pid = os.getpid() 

179 stale_threshold = _validate_intervals(heartbeat_interval, stale_threshold, poll_interval) 

180 

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

182 self.timeout: float = timeout 

183 self.blocking: bool = blocking 

184 self.heartbeat_interval: float = heartbeat_interval 

185 self.stale_threshold: float = stale_threshold 

186 self.poll_interval: float = poll_interval 

187 

188 self._paths = _Paths( 

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

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

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

192 ) 

193 ensure_directory_exists(self.lock_file) 

194 self._locks = _Locks( 

195 internal=threading.Lock(), 

196 transaction=threading.Lock(), 

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

198 ) 

199 self._readers_dir_fd: int | None = None 

200 self._readers_dir_fd_token: int | None = None 

201 self._hold: _Hold | None = None 

202 self._closed: bool = False 

203 

204 with _ALL_INSTANCES_LOCK: 

205 _ALL_INSTANCES[id(self)] = self 

206 _register_fork_object(self) 

207 

208 @classmethod 

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

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

211 _ALL_INSTANCES_LOCK = threading.Lock() 

212 cls._instances = WeakValueDictionary() 

213 cls._instances_lock = threading.RLock() 

214 _SINGLETONS_UNDER_CONSTRUCTION.clear() 

215 

216 @contextmanager 

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

218 """ 

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

220 

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

222 

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

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

225 

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

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

228 

229 """ 

230 self.acquire_read(timeout, blocking=blocking) 

231 try: 

232 yield 

233 finally: 

234 self.release() 

235 

236 @contextmanager 

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

238 """ 

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

240 

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

242 

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

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

245 

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

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

248 

249 """ 

250 self.acquire_write(timeout, blocking=blocking) 

251 try: 

252 yield 

253 finally: 

254 self.release() 

255 

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

257 """ 

258 Acquire a shared read lock. 

259 

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

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

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

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

264 

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

266 indefinitely 

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

268 ``None`` uses the instance default 

269 

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

271 

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

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

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

275 

276 """ 

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

278 

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

280 """ 

281 Acquire an exclusive write lock. 

282 

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

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

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

286 :class:`RuntimeError`. 

287 

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

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

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

291 

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

293 indefinitely 

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

295 ``None`` uses the instance default 

296 

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

298 

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

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

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

302 

303 """ 

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

305 

306 @classmethod 

307 def get_lock( 

308 cls, 

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

310 timeout: float = -1, 

311 *, 

312 blocking: bool = True, 

313 ) -> SoftReadWriteLock: 

314 """ 

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

316 

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

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

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

320 

321 :returns: the singleton lock instance 

322 

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

324 

325 """ 

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

327 

328 def close(self) -> None: 

329 """ 

330 Release any held lock and release internal filesystem resources. 

331 

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

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

334 """ 

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

336 return 

337 self.release(force=True) 

338 with self._locks.internal: 

339 if self._closed: 

340 return 

341 self._closed = True 

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

343 with _fork_transition(): 

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

345 _unregister_owned_descriptor(self._readers_dir_fd_token) 

346 self._readers_dir_fd_token = None 

347 fd, self._readers_dir_fd = self._readers_dir_fd, None 

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

349 os.close(fd) 

350 

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

352 """ 

353 Release one level of the current lock. 

354 

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

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

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

358 

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

360 

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

362 

363 """ 

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

365 return 

366 with self._locks.internal: 

367 hold = self._hold 

368 if hold is None: 

369 if force: 

370 return 

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

372 raise RuntimeError(msg) 

373 if force: 

374 hold.level = 0 

375 else: 

376 hold.level -= 1 

377 if hold.level > 0: 

378 return 

379 self._hold = None 

380 

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

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

383 hold.heartbeat_stop.set() 

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

385 if hold.is_reader: 

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

387 else: 

388 self._unlink_writer_marker_if_ours(hold.token) 

389 

390 def _unlink_writer_marker_if_ours(self, token: str, *, blocking: bool = True) -> None: 

391 # Serialize the token check and unlink so a paused holder cannot remove its successor's marker. 

392 try: 

393 with self._locks.state.acquire(blocking=blocking): 

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

395 return 

396 info, _ = read 

397 if info is not None and hmac.compare_digest(info.token, token): 

398 _unlink(self._paths.write) 

399 except Timeout: 

400 # Failed acquisition must return on time; peers can reclaim the unrefreshed writer marker. 

401 return 

402 

403 def _acquire( 

404 self, 

405 mode: _Mode, 

406 timeout: float | None, 

407 *, 

408 blocking: bool | None, 

409 ) -> AcquireReturnProxy: 

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

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

412 raise RuntimeError(msg) 

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

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

415 

416 with self._locks.internal: 

417 if self._closed: 

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

419 raise RuntimeError(msg) 

420 if self._hold is not None: 

421 return self._validate_reentrant(mode) 

422 

423 start = time.perf_counter() 

424 if not blocking: 

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

426 elif timeout == -1: 

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

428 else: 

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

430 if not acquired: 

431 raise Timeout(self.lock_file) from None 

432 try: 

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

434 finally: 

435 self._locks.transaction.release() 

436 

437 def _do_acquire_inner( 

438 self, 

439 mode: _Mode, 

440 effective_timeout: float, 

441 start: float, 

442 *, 

443 blocking: bool, 

444 ) -> AcquireReturnProxy: 

445 with self._locks.internal: 

446 if self._hold is not None: 

447 return self._validate_reentrant(mode) 

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

449 token = secrets.token_hex(16) 

450 if mode == "write": 

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

452 else: 

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

454 stop_event = threading.Event() 

455 heartbeat = _HeartbeatThread( 

456 refresh=self._refresh_marker, 

457 interval=self.heartbeat_interval, 

458 stop_event=stop_event, 

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

460 ) 

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

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

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

464 # believes it holds the lock. 

465 start_error: BaseException | None = None 

466 with self._locks.internal: 

467 self._hold = _Hold( 

468 level=1, 

469 mode=mode, 

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

471 marker_name=marker_name, 

472 is_reader=is_reader, 

473 token=token, 

474 heartbeat_thread=heartbeat, 

475 heartbeat_stop=stop_event, 

476 ) 

477 try: 

478 heartbeat.start() 

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

480 self._hold = None 

481 start_error = error 

482 if start_error is not None: 

483 if is_reader: 

484 _unlink(marker_name, dir_fd=self._readers_dir_fd) 

485 else: 

486 self._unlink_writer_marker_if_ours(token, blocking=False) 

487 raise start_error 

488 return AcquireReturnProxy(lock=self) 

489 

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

491 hold = self._hold 

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

493 if hold.mode != mode: 

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

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

496 msg = ( 

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

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

499 ) 

500 raise RuntimeError(msg) 

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

502 msg = ( 

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

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

505 ) 

506 raise RuntimeError(msg) 

507 hold.level += 1 

508 return AcquireReturnProxy(lock=self) 

509 

510 def _acquire_writer_slot( 

511 self, 

512 token: str, 

513 *, 

514 deadline: float | None, 

515 blocking: bool, 

516 ) -> tuple[str, bool]: 

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

518 # create files inside. 

519 self._open_readers_dir() 

520 

521 def try_claim_writer() -> bool: 

522 return self._claim_writer_marker(token) 

523 

524 def readers_drained_touching() -> bool: 

525 # A paused contender must reclaim its slot before proceeding if a peer replaced its marker. 

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

527 return False 

528 self._break_stale_readers(time.time()) 

529 return not self._any_readers() 

530 

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

532 try: 

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

534 except Timeout: 

535 self._unlink_writer_marker_if_ours(token, blocking=False) 

536 raise 

537 return self._paths.write, False 

538 

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

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

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

542 # slot is waited out instead of overwritten. 

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

544 if _file_exists(self._paths.write): 

545 return False 

546 try: 

547 _atomic_create_marker(self._paths.write, token) 

548 except FileExistsError: 

549 return False 

550 return True 

551 

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

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

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

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

556 fd = _open_marker(self._paths.write) 

557 if fd is None: 

558 return False 

559 try: 

560 try: 

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

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

563 return False 

564 info = _parse_marker_bytes(data) 

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

566 return False 

567 with suppress(OSError): 

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

569 return True 

570 finally: 

571 os.close(fd) 

572 

573 def _acquire_reader_slot( 

574 self, 

575 token: str, 

576 *, 

577 deadline: float | None, 

578 blocking: bool, 

579 ) -> tuple[str, bool]: 

580 self._open_readers_dir() 

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

582 dir_fd = self._readers_dir_fd 

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

584 

585 def try_claim_reader() -> bool: 

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

587 if _file_exists(self._paths.write): 

588 return False 

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

590 _atomic_create_marker(reader_name, token, dir_fd=dir_fd) 

591 else: # pragma: win32 cover 

592 _atomic_create_marker(full_reader_path, token) 

593 return True 

594 

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

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

597 

598 def _wait_for( 

599 self, 

600 predicate: Callable[[], bool], 

601 *, 

602 deadline: float | None, 

603 blocking: bool, 

604 ) -> None: 

605 while True: 

606 # One retry loop owns the deadline, including contention on the state mutex. 

607 try: 

608 with self._locks.state.acquire(blocking=False): 

609 if predicate(): 

610 return 

611 except Timeout: 

612 pass 

613 now = time.perf_counter() 

614 if not blocking: 

615 raise Timeout(self.lock_file) 

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

617 raise Timeout(self.lock_file) 

618 sleep_for = self.poll_interval 

619 if deadline is not None: 

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

621 time.sleep(sleep_for) 

622 

623 def _open_readers_dir(self) -> None: 

624 readers_path = Path(self._paths.readers) 

625 with suppress(FileExistsError): 

626 readers_path.mkdir(mode=0o700) 

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

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

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

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

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

632 raise RuntimeError(msg) 

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

634 with _fork_transition(): 

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

636 try: 

637 token = _register_owned_descriptor(fd) 

638 except BaseException as registration_error: 

639 try: 

640 os.close(fd) 

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

642 _raise_grouped_errors( 

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

644 registration_error, 

645 close_error, 

646 ) 

647 raise 

648 self._readers_dir_fd = fd 

649 self._readers_dir_fd_token = token 

650 

651 def _any_readers(self) -> bool: 

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

653 for _ in entries: 

654 return True 

655 return False 

656 

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

658 """ 

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

660 

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

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

663 

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

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

666 """ 

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

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

669 for entry in it: 

670 if not _is_housekeeping_name(entry.name): 

671 yield entry.name, True 

672 return 

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

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

675 for entry in it: # pragma: win32 cover 

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

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

678 

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

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

681 try: 

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

683 for name, dirfd_relative in entries: 

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

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

686 return 

687 for name, fd in names: 

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

689 

690 def _refresh_marker(self) -> bool: 

691 with self._locks.internal: 

692 hold = self._hold 

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

694 return False 

695 marker_name = hold.marker_name 

696 token = hold.token 

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

698 

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

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

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

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

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

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

705 # silently drop a held lock. 

706 try: 

707 fd = _open_marker_fd(marker_name, dir_fd=dir_fd) 

708 except FileNotFoundError: 

709 return False 

710 except OSError: 

711 return True 

712 try: 

713 try: 

714 data = _read_marker_fd(fd) 

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

716 return True 

717 info = _parse_marker_bytes(data) 

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

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

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

721 return False 

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

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

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

725 with suppress(OSError): 

726 touch(marker_name, fd=fd) 

727 return True 

728 finally: 

729 os.close(fd) 

730 

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

732 self._locks = _Locks( 

733 internal=threading.Lock(), 

734 transaction=threading.Lock(), 

735 state=self._locks.state, 

736 ) 

737 self._hold = None 

738 self._readers_dir_fd = None 

739 self._readers_dir_fd_token = None 

740 

741 

742class _HeartbeatThread(threading.Thread): 

743 def __init__( 

744 self, 

745 refresh: Callable[[], bool], 

746 interval: float, 

747 stop_event: threading.Event, 

748 name: str, 

749 ) -> None: 

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

751 self._refresh = refresh 

752 self._interval = interval 

753 self._stop_event = stop_event 

754 

755 def run(self) -> None: 

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

757 if not self._refresh(): 

758 self._stop_event.set() 

759 return 

760 

761 

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

763 fd = _open_marker(name, dir_fd=dir_fd) 

764 if fd is None: 

765 return None 

766 try: 

767 st = os.fstat(fd) 

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

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

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

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

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

773 return None, st.st_mtime 

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

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

776 return None 

777 finally: 

778 os.close(fd) 

779 return _parse_marker_bytes(data), st.st_mtime 

780 

781 

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

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

784 

785 

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

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

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

789 # that holds the state lock. 

790 flags = os.O_RDONLY | _O_NOFOLLOW | _O_NONBLOCK 

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

792 

793 

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

795 try: 

796 return _open_marker_fd(name, dir_fd=dir_fd) 

797 except OSError: 

798 return None 

799 

800 

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

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

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

804 # despite being defined inline. 

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

806 return None 

807 try: 

808 text = data.decode("ascii") 

809 except UnicodeDecodeError: 

810 return None 

811 match = re.match( 

812 r""" 

813 \A # start of string 

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

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

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

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

818 \Z # end of string 

819 """, 

820 text, 

821 re.VERBOSE, 

822 ) 

823 if match is None: 

824 return None 

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

826 if pid > 2**31 - 1: 

827 return None 

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

829 

830 

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

832 with suppress(FileNotFoundError): 

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

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

835 os.unlink(name, dir_fd=dir_fd) 

836 else: 

837 Path(name).unlink() 

838 

839 

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

841 name: str, 

842 *, 

843 stale_threshold: float, 

844 now: float, 

845 dir_fd: int | None = None, 

846) -> bool: 

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

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

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

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

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

852 return False 

853 info_before, mtime_before = read_result 

854 if now - mtime_before <= stale_threshold: 

855 return False 

856 if info_before is None: 

857 _unlink(name, dir_fd=dir_fd) 

858 return True 

859 

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

861 try: 

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

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

864 else: 

865 Path(name).rename(break_name) 

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

867 return False 

868 

869 read_after = _read_marker(break_name, dir_fd=dir_fd) 

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

871 return False 

872 info_after, mtime_after = read_after 

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

874 _unlink(break_name, dir_fd=dir_fd) 

875 return True 

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

877 return False 

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

879 return False 

880 _unlink(break_name, dir_fd=dir_fd) 

881 return True 

882 

883 

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

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

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

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

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

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

890 else: 

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

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

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

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

895 try: 

896 st = os.fstat(fd) 

897 identity = st.st_dev, st.st_ino 

898 write_all(fd, f"{token}\n{os.getpid()}\n{host_name()}\n".encode("ascii")) 

899 except BaseException: 

900 os.close(fd) 

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

902 _unlink(name, dir_fd=dir_fd) 

903 raise 

904 else: 

905 os.close(fd) 

906 

907 

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

909 try: 

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

911 except OSError: 

912 return False 

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

914 

915 

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

917 try: 

918 st = os.lstat(path) 

919 except FileNotFoundError: 

920 return False 

921 return stat.S_ISREG(st.st_mode) 

922 

923 

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

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

926 

927 

928def _validate_intervals(heartbeat_interval: float, stale_threshold: float | None, poll_interval: float) -> float: 

929 if not isfinite(heartbeat_interval) or heartbeat_interval <= 0: 

930 msg = f"heartbeat_interval must be positive and finite, got {heartbeat_interval}" 

931 raise ValueError(msg) 

932 if stale_threshold is None: 

933 stale_threshold = heartbeat_interval * 3 

934 if not isfinite(stale_threshold) or stale_threshold <= heartbeat_interval: 

935 msg = ( 

936 f"stale_threshold must exceed heartbeat_interval ({heartbeat_interval}) " 

937 f"and be finite, got {stale_threshold}" 

938 ) 

939 raise ValueError(msg) 

940 if not isfinite(poll_interval) or poll_interval <= 0: 

941 msg = f"poll_interval must be positive and finite, got {poll_interval}" 

942 raise ValueError(msg) 

943 return stale_threshold 

944 

945 

946@dataclass(frozen=True) 

947class _Paths: 

948 state: str 

949 write: str 

950 readers: str 

951 

952 

953@dataclass 

954class _Locks: 

955 internal: threading.Lock 

956 transaction: threading.Lock 

957 state: SoftFileLock 

958 

959 

960@dataclass(frozen=True) 

961class _MarkerInfo: 

962 token: str 

963 pid: int 

964 hostname: str 

965 

966 

967@dataclass 

968class _Hold: 

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

970 

971 level: int 

972 mode: _Mode 

973 write_thread_id: int | None 

974 marker_name: str 

975 is_reader: bool 

976 token: str 

977 heartbeat_thread: _HeartbeatThread 

978 heartbeat_stop: threading.Event 

979 

980 

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

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

983 with suppress(Exception): 

984 instance.release(force=True) 

985 

986 

987atexit.register(_cleanup_all_instances) 

988_register_fork_class(SoftReadWriteLock) 

989 

990 

991__all__ = [ 

992 "SoftReadWriteLock", 

993]