Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/filelock/_api.py: 50%

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

920 statements  

1from __future__ import annotations 

2 

3import contextlib 

4import inspect 

5import logging 

6import math 

7import os 

8import secrets 

9import sys 

10import time 

11import warnings 

12from abc import ABCMeta, abstractmethod 

13from collections.abc import Callable, Hashable 

14from contextlib import contextmanager 

15from dataclasses import dataclass 

16from itertools import count, starmap 

17from threading import Condition, RLock, get_ident, local 

18from typing import TYPE_CHECKING, Final, Literal, NoReturn, TypedDict, TypeVar, cast 

19from weakref import WeakKeyDictionary, WeakValueDictionary 

20 

21from ._error import SoftFileLockLifetimeWarning, Timeout 

22from ._util import break_lock_file 

23 

24#: No explicit file permission mode was passed. Lock files then open with 0o666 so umask and default ACLs pick 

25#: the final permissions, and fchmod is skipped to preserve POSIX default ACL inheritance. 

26_UNSET_FILE_MODE: Final[int] = -1 

27 

28#: Ceiling on the retry counter used as a power of two, so a long contended wait cannot overflow the backoff multiply. 

29_MAX_BACKOFF_EXPONENT: Final[int] = 20 

30 

31#: How a context manager reconciles a body failure with a release failure on exit (see the property of this name). 

32ContextErrorPolicy = Literal["chain", "group"] 

33_CONTEXT_ERROR_POLICIES: Final[frozenset[str]] = frozenset({"chain", "group"}) 

34 

35#: What a descriptor-owning backend does with an ``os.close`` failure after relinquishing ownership (see the property). 

36CloseErrorPolicy = Literal["default", "raise", "suppress"] 

37_CLOSE_ERROR_POLICIES: Final[frozenset[str]] = frozenset({"default", "raise", "suppress"}) 

38 

39if TYPE_CHECKING: 

40 from collections.abc import Generator 

41 from types import TracebackType 

42 from typing import Protocol 

43 

44 from _typeshed import Unused 

45 

46 from ._read_write import ReadWriteLock 

47 from ._soft_rw import SoftReadWriteLock 

48 

49 class _ForkResettable(Protocol): 

50 def _reset_after_fork_in_child(self) -> None: ... 

51 

52 class _ForkDescriptorOwner(Protocol): 

53 def _descriptors_for_fork(self) -> tuple[tuple[int, tuple[int, int] | None], ...]: ... 

54 

55 # Matched against the class object itself rather than `type[...]` of it. A metaclass supplies this method to the 

56 # class while leaving instances without it, so a `type[_ForkResettableClass]` bound rejects `ReadWriteLock`. 

57 class _ForkResettableClass(Protocol): 

58 def _reset_class_after_fork(self) -> None: ... 

59 

60 class _RegisterAtFork(Protocol): 

61 def __call__( 

62 self, 

63 *, 

64 before: Callable[[], None] | None = None, 

65 after_in_parent: Callable[[], None] | None = None, 

66 after_in_child: Callable[[], None] | None = None, 

67 ) -> None: ... 

68 

69 if sys.version_info >= (3, 11): # pragma: no cover (py311+) 

70 from typing import Self 

71 else: # pragma: no cover (<py311) 

72 from typing_extensions import Self 

73 

74_LOGGER: Final[logging.Logger] = logging.getLogger("filelock") 

75_REGISTER_AT_FORK: Final[_RegisterAtFork | None] = cast("_RegisterAtFork | None", getattr(os, "register_at_fork", None)) 

76_HAS_REGISTER_AT_FORK: Final[bool] = _REGISTER_AT_FORK is not None 

77 

78_ExtraValue = TypeVar("_ExtraValue") 

79_MarkerValue = TypeVar("_MarkerValue") 

80_SubclassValue = TypeVar("_SubclassValue") 

81_LockInitValue = float | int | bool | str | None | Callable[[int], None] 

82 

83 

84class LockOptions(TypedDict, total=False): 

85 """Every option the metaclass forwards, so a subclass adding its own can still type what it passes through.""" 

86 

87 timeout: float 

88 mode: int 

89 thread_local: bool 

90 blocking: bool 

91 is_singleton: bool 

92 poll_interval: float 

93 lifetime: float | None 

94 context_error_policy: ContextErrorPolicy 

95 close_error_policy: CloseErrorPolicy 

96 fallback_to_soft: bool 

97 preserve_lock_file: bool 

98 on_acquired: Callable[[int], None] | None 

99 

100 

101def _exception_group_cls() -> type[BaseException]: 

102 # BaseExceptionGroup is a builtin on 3.11+; on 3.10 it needs the exceptiongroup backport. filelock keeps zero 

103 # runtime dependencies, so the backport is imported lazily rather than required, and only group mode needs it. 

104 if sys.version_info >= (3, 11): # pragma: no cover (py311+) 

105 return BaseExceptionGroup # ruff:ignore[undefined-name] # builtin on 3.11+ 

106 # Alias the import so BaseExceptionGroup above stays the builtin rather than an unbound local of this function. 

107 from exceptiongroup import ( # ruff:ignore[import-outside-top-level] # pragma: no cover (<py311) 

108 BaseExceptionGroup as _Backport, 

109 ) 

110 

111 return _Backport # pragma: no cover (<py311) 

112 

113 

114def _raise_grouped_errors( 

115 message: str, 

116 first_error: BaseException, 

117 second_error: BaseException, 

118 *additional_errors: BaseException, 

119 marker: tuple[str, _MarkerValue] | None = None, 

120) -> NoReturn: 

121 errors = (first_error, second_error, *additional_errors) 

122 _detach_grouped_contexts(errors) 

123 group = _exception_group_cls()(message, errors) 

124 if marker is not None: 

125 setattr(group, marker[0], marker[1]) 

126 raise group from None 

127 

128 

129def _detach_grouped_contexts(errors: tuple[BaseException, ...]) -> None: 

130 seen: set[int] = set() 

131 pending = list(errors) 

132 while pending: 

133 error = pending.pop() 

134 if id(error) in seen: 

135 continue 

136 seen.add(id(error)) 

137 if (context := error.__context__) is not None and ( 

138 context is error 

139 or _same_exception_tree(error, context) 

140 or any(context is root or _contains_exception(root, context) for root in errors) 

141 ): 

142 error.__context__ = None 

143 elif context is not None: 

144 pending.append(context) 

145 if error.__cause__ is not None: 

146 pending.append(error.__cause__) 

147 if isinstance(error, _exception_group_cls()): 

148 pending.extend(cast("_ExceptionGroupProtocol", error).exceptions) 

149 

150 

151def _same_exception_tree(first: BaseException, second: BaseException) -> bool: 

152 pending = [(first, second)] 

153 seen: set[tuple[int, int]] = set() 

154 while pending: 

155 first_error, second_error = pending.pop() 

156 if first_error is second_error: 

157 continue 

158 if (pair := (id(first_error), id(second_error))) in seen: 

159 continue 

160 seen.add(pair) 

161 if ( 

162 type(first_error) is not type(second_error) 

163 or not isinstance(first_error, _exception_group_cls()) 

164 or not isinstance(second_error, _exception_group_cls()) 

165 ): 

166 return False 

167 first_group = cast("_ExceptionGroupProtocol", first_error) 

168 second_group = cast("_ExceptionGroupProtocol", second_error) 

169 if first_group.message != second_group.message or len(first_group.exceptions) != len(second_group.exceptions): 

170 return False 

171 pending.extend(zip(first_group.exceptions, second_group.exceptions, strict=True)) 

172 return True 

173 

174 

175def _contains_exception(error: BaseException, target: BaseException | None) -> bool: 

176 if target is None or not isinstance(error, _exception_group_cls()): 

177 return False 

178 pending = list(cast("_ExceptionGroupProtocol", error).exceptions) 

179 seen: set[int] = set() 

180 while pending: 

181 child = pending.pop() 

182 if child is target: 

183 return True 

184 if id(child) in seen: 

185 continue 

186 seen.add(id(child)) 

187 if isinstance(child, _exception_group_cls()): 

188 pending.extend(cast("_ExceptionGroupProtocol", child).exceptions) 

189 return False 

190 

191 

192def _append_exception_context(error: BaseException, context: BaseException) -> None: 

193 if _exception_graph_contains(error, context) or _exception_graph_contains(context, error): 

194 return 

195 if error.__context__ is None: 

196 error.__context__ = context 

197 return 

198 tail = error 

199 seen: set[int] = set() 

200 while id(tail) not in seen: 

201 seen.add(id(tail)) 

202 if (next_error := tail.__cause__ if tail.__cause__ is not None else tail.__context__) is None: 

203 tail.__context__ = context 

204 return 

205 tail = next_error 

206 

207 

208def _exception_graph_contains(error: BaseException, target: BaseException) -> bool: 

209 pending = [error] 

210 seen: set[int] = set() 

211 while pending: 

212 current = pending.pop() 

213 if current is target: 

214 return True 

215 if id(current) in seen: # pragma: no cover - arbitrary caller exceptions can contain cycles 

216 continue 

217 seen.add(id(current)) 

218 if current.__cause__ is not None: 

219 pending.append(current.__cause__) 

220 if current.__context__ is not None: 

221 pending.append(current.__context__) 

222 if isinstance(current, _exception_group_cls()): 

223 pending.extend(cast("_ExceptionGroupProtocol", current).exceptions) 

224 return False 

225 

226 

227def _grouped_errors( 

228 error: BaseException, message: str, marker: tuple[str, _MarkerValue] 

229) -> tuple[BaseException, ...] | None: 

230 if not isinstance(error, _exception_group_cls()): 

231 return None 

232 group = cast("_ExceptionGroupProtocol", error) 

233 return group.exceptions if group.message == message and getattr(group, marker[0], None) is marker[1] else None 

234 

235 

236if TYPE_CHECKING: 

237 

238 class _ExceptionGroupProtocol(Protocol): 

239 @property 

240 def message(self) -> str: ... 

241 

242 @property 

243 def exceptions(self) -> tuple[BaseException, ...]: ... 

244 

245 

246def _raise_chained_errors(first_error: BaseException, second_error: BaseException | None = None) -> NoReturn: 

247 if second_error is None: 

248 first_context = first_error.__context__ 

249 try: 

250 raise first_error # ruff:ignore[raise-within-try] # the handler restores caller-supplied context before propagation 

251 except BaseException: 

252 first_error.__context__ = first_context 

253 raise 

254 if (second_context := second_error.__context__) is not None and second_context is not first_error: 

255 _detach_exception_context(second_context, first_error) 

256 _append_exception_context(first_error, second_context) 

257 first_context = first_error.__context__ 

258 try: 

259 raise first_error # ruff:ignore[raise-within-try] # the second raise needs this error as implicit context 

260 except BaseException: # ruff:ignore[blind-except] # first_error may be a control-flow exception 

261 first_error.__context__ = first_context 

262 try: 

263 raise second_error # ruff:ignore[raise-within-try] # the handler makes the chain interpreter-independent 

264 except BaseException: 

265 second_error.__context__ = first_error 

266 first_error.__context__ = first_context 

267 raise 

268 

269 

270def _detach_exception_context(error: BaseException, target: BaseException) -> None: 

271 pending = [error] 

272 seen: set[int] = set() 

273 while pending: 

274 current = pending.pop() 

275 if id(current) in seen: 

276 continue 

277 seen.add(id(current)) 

278 if current.__context__ is target: 

279 current.__context__ = None 

280 elif current.__context__ is not None: 

281 pending.append(current.__context__) 

282 if current.__cause__ is not None: 

283 pending.append(current.__cause__) 

284 if isinstance(current, _exception_group_cls()): 

285 pending.extend(cast("_ExceptionGroupProtocol", current).exceptions) 

286 

287 

288def _raise_body_and_release(body_error: BaseException, release_error: BaseException) -> NoReturn: 

289 # Group mode: surface the body failure and the release failure as sibling leaves instead of letting one hide in the 

290 # other's __context__. BaseExceptionGroup returns a plain ExceptionGroup when both leaves subclass Exception, so 

291 # ``except*`` and ``except Exception`` still catch them; a BaseException leaf (KeyboardInterrupt, CancelledError) 

292 # keeps the group outside ordinary handlers. ``from None`` stops the group itself gaining a redundant __context__. 

293 _raise_grouped_errors("lock body and release both failed", body_error, release_error) 

294 

295 

296def _raise_cleanup_errors( 

297 message: str, 

298 primary_error: BaseException, 

299 *cleanup_errors: BaseException | None, 

300) -> NoReturn: 

301 _raise_grouped_errors( 

302 message, 

303 primary_error, 

304 *(error for error in cleanup_errors if error is not None), 

305 ) 

306 

307 

308# On Windows os.path.realpath calls CreateFileW with share_mode=0, which blocks concurrent DeleteFileW and causes 

309# livelocks under threaded contention with SoftFileLock. os.path.abspath is purely string-based and avoids this. 

310_resolve_dir: Final[Callable[[str], str]] = os.path.abspath if sys.platform == "win32" else os.path.realpath 

311 

312 

313def _canonical(path: str | os.PathLike[str]) -> str: 

314 """ 

315 Return one stable key for *path*, collapsing equivalent spellings without following a final symlink. 

316 

317 Relative, absolute, and ``./`` spellings of one lock file must map to a single singleton instance, deadlock-registry 

318 entry, and removal key. Resolving the whole path with ``realpath`` would follow a final symlink and alias a lock 

319 target the backend deliberately rejects, so the registry identity would differ from the backend's. Resolving only 

320 the parent directory and re-appending the literal final component collapses the equivalent spellings while keeping a 

321 final symlink a distinct key. On Windows the parent is resolved with ``abspath`` so junctions and reparse points are 

322 not followed either. 

323 """ 

324 parent, name = os.path.split(os.fspath(path)) 

325 return os.path.join(_resolve_dir(parent or os.curdir), name) # ruff:ignore[os-path-join] # string join matches abspath/realpath 

326 

327 

328class _ThreadLocalRegistry(local): 

329 def __init__(self) -> None: 

330 super().__init__() 

331 self.held: dict[Hashable, int] = {} 

332 

333 

334_registry: Final[_ThreadLocalRegistry] = _ThreadLocalRegistry() 

335 

336 

337_T = TypeVar("_T", bound="BaseFileLock") 

338 

339 

340class FileLockMeta(ABCMeta): 

341 _instances: WeakValueDictionary[str, BaseFileLock] 

342 _instances_lock: RLock 

343 _instances_under_construction: set[str] 

344 

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

346 cls: type[_T], 

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

348 timeout: float = -1, 

349 mode: int = _UNSET_FILE_MODE, 

350 thread_local: bool = True, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility 

351 *, 

352 blocking: bool = True, 

353 is_singleton: bool = False, 

354 poll_interval: float = 0.05, 

355 lifetime: float | None = None, 

356 context_error_policy: ContextErrorPolicy = "chain", 

357 close_error_policy: CloseErrorPolicy = "default", 

358 fallback_to_soft: bool = True, 

359 preserve_lock_file: bool = False, 

360 on_acquired: Callable[[int], None] | None = None, 

361 **kwargs: _ExtraValue, 

362 ) -> _T: 

363 _ensure_current_process() 

364 lifetime = _resolve_lifetime(lifetime, cls, stacklevel=cls._constructor_lifetime_warning_stacklevel) 

365 # Validate before building the instance: a raise inside __init__ would leave a half-constructed object whose 

366 # __del__ then trips over the missing context. 

367 context_error_policy = _resolve_context_error_policy(context_error_policy) 

368 close_error_policy = _resolve_close_error_policy(close_error_policy) 

369 preserve_lock_file = _resolve_preserve_lock_file( 

370 preserve=preserve_lock_file, supported=cls._preserve_lock_file_supported, cls_name=cls.__name__ 

371 ) 

372 on_acquired = _resolve_on_acquired(on_acquired, supported=cls._on_acquired_supported, cls_name=cls.__name__) 

373 params: dict[str, _LockInitValue | _ExtraValue] = { 

374 "timeout": timeout, 

375 "mode": mode, 

376 "thread_local": thread_local, 

377 "blocking": blocking, 

378 "is_singleton": is_singleton, 

379 "poll_interval": poll_interval, 

380 "lifetime": lifetime, 

381 "context_error_policy": context_error_policy, 

382 "close_error_policy": close_error_policy, 

383 "fallback_to_soft": fallback_to_soft, 

384 "preserve_lock_file": preserve_lock_file, 

385 "on_acquired": on_acquired, 

386 **kwargs, 

387 } 

388 if not is_singleton: 

389 return cls._create_instance(lock_file, params) 

390 

391 # Look up, build and store under one lock. Without it two threads racing the first construction for a 

392 # path both miss the cache and each build their own instance, so callers relying on is_singleton for 

393 # reentrant locking across instances end up with two "singletons" and acquire()'s deadlock check then 

394 # rejects a legitimate reentrant acquire; the unguarded writes to the WeakValueDictionary are a data 

395 # race besides. ReadWriteLock and SoftReadWriteLock already guard their singleton caches this way. 

396 # Key the cache on the canonical form so equivalent spellings of one path share a singleton, and it matches the 

397 # deadlock-registry key acquire() uses. 

398 singleton_key = _canonical(lock_file) 

399 with cls._instances_lock: 

400 if (instance := cls._instances.get(singleton_key)) is None: 

401 if singleton_key in cls._instances_under_construction: # pragma: needs fork 

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

403 raise RuntimeError(msg) 

404 construction_registry = cls._instances_under_construction 

405 construction_pid = os.getpid() 

406 construction_registry.add(singleton_key) 

407 try: 

408 instance = cls._create_instance(lock_file, params) 

409 finally: 

410 construction_registry.discard(singleton_key) 

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

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

413 raise RuntimeError(msg) 

414 cls._instances[singleton_key] = instance 

415 return instance 

416 

417 params_to_check = { 

418 "thread_local": (thread_local, instance.is_thread_local()), 

419 "timeout": (timeout, instance.timeout), 

420 "mode": (mode, instance._context.mode), # ruff:ignore[private-member-access] # compares against the managed instance's own context 

421 "blocking": (blocking, instance.blocking), 

422 "poll_interval": (poll_interval, instance.poll_interval), 

423 "lifetime": (lifetime, instance.lifetime), 

424 "context_error_policy": (context_error_policy, instance.context_error_policy), 

425 "close_error_policy": (close_error_policy, instance.close_error_policy), 

426 "fallback_to_soft": (fallback_to_soft, instance.fallback_to_soft), 

427 "preserve_lock_file": (preserve_lock_file, instance.preserve_lock_file), 

428 } 

429 non_matching_params = { 

430 name: (passed_param, set_param) 

431 for name, (passed_param, set_param) in params_to_check.items() 

432 if passed_param != set_param 

433 } 

434 # Callables compare by identity, not equality: two equal callables can close over different state, so a 

435 # singleton must reject a different hook object even if it compares equal. Keep it out of the scalar dict above. 

436 hook_mismatch = on_acquired is not instance.on_acquired 

437 if not non_matching_params and not hook_mismatch: 

438 return instance # ty: ignore[invalid-return-type] # https://github.com/astral-sh/ty/issues/3231 

439 

440 msg = "Singleton lock instances cannot be initialized with differing arguments" 

441 msg += "\nNon-matching arguments: " 

442 for param_name, (passed_param, set_param) in non_matching_params.items(): 

443 msg += f"\n\t{param_name} (existing lock has {set_param} but {passed_param} was passed)" 

444 if hook_mismatch: 

445 msg += f"\n\ton_acquired (existing lock has {instance.on_acquired} but {on_acquired} was passed)" 

446 raise ValueError(msg) 

447 

448 def _create_instance( 

449 cls: type[_T], lock_file: str | os.PathLike[str], params: dict[str, _LockInitValue | _ExtraValue] 

450 ) -> _T: 

451 model = _init_parameter_model(cls) 

452 if model.accepts_kwargs: 

453 return super().__call__(lock_file, **params) 

454 

455 unsupported = sorted( 

456 name 

457 for name, value in params.items() 

458 if name not in model.accepted_params 

459 and ((parameter := model.default_params.get(name)) is None or value != parameter.default) 

460 ) 

461 if unsupported: 

462 msg = f"{cls.__name__} does not support non-default lock options: {', '.join(unsupported)}" 

463 raise TypeError(msg) 

464 # virtualenv narrows a BaseFileLock descendant's signature; omit base defaults it does not accept (#340). 

465 return super().__call__( 

466 lock_file, 

467 **{name: value for name, value in params.items() if name in model.accepted_params}, 

468 ) 

469 

470 

471_INIT_PARAMETER_MODELS: Final[WeakKeyDictionary[type[BaseFileLock], _InitParameterModel]] = WeakKeyDictionary() 

472 

473 

474def _init_parameter_model(cls: type[BaseFileLock]) -> _InitParameterModel: 

475 # A strong cache would keep dynamically created subclasses alive for the process lifetime. 

476 with _fork_transition(), _FORK_STATE.parameter_models_lock: 

477 if (model := _INIT_PARAMETER_MODELS.get(cls)) is None: 

478 parameters = inspect.signature(cls.__init__).parameters.values() 

479 model = _InitParameterModel( 

480 accepted_params=frozenset( 

481 parameter.name 

482 for parameter in parameters 

483 if parameter.kind in {inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY} 

484 ), 

485 accepts_kwargs=any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters), 

486 default_params={ 

487 name: parameter 

488 for name, parameter in inspect.signature(type(cls).__call__).parameters.items() 

489 if parameter.default is not inspect.Parameter.empty 

490 }, 

491 ) 

492 _INIT_PARAMETER_MODELS[cls] = model 

493 return model 

494 

495 

496@dataclass(frozen=True) 

497class _InitParameterModel: 

498 accepted_params: frozenset[str] 

499 accepts_kwargs: bool 

500 default_params: dict[str, inspect.Parameter] 

501 

502 

503def _resolve_lifetime(lifetime: float | None, cls: type[BaseFileLock], *, stacklevel: int) -> float | None: 

504 """ 

505 Validate ``lifetime`` and drop a value the backend cannot honor. 

506 

507 ``lifetime`` is a deliberate age-based lease: a lock file older than ``lifetime`` is broken even while its holder is 

508 still alive. Existence locks (:class:`SoftFileLock`) implement that behavior by unlinking a reclaimable pathname, 

509 which can overlap a live holder. A native OS lock lives on the inode, so unlinking the pathname by age cannot revoke 

510 the kernel lock; a contender would lock a fresh inode and overlap the live holder (#590). Ignore the request with a 

511 warning rather than accept a setting that breaks mutual exclusion. 

512 """ 

513 if lifetime is not None: 

514 if isinstance(lifetime, bool) or not isinstance(lifetime, (int, float)): 

515 msg = f"lifetime must be a finite non-negative number or None, not {type(lifetime).__name__}" 

516 raise TypeError(msg) 

517 if lifetime < 0 or (isinstance(lifetime, float) and not math.isfinite(lifetime)): 

518 msg = f"lifetime must be finite and non-negative, not {lifetime!r}" 

519 raise ValueError(msg) 

520 if lifetime is not None and not cls._lifetime_supported: 

521 warnings.warn( 

522 f"lifetime is ignored for {cls.__name__}: {cls._lifetime_unsupported_reason}; " 

523 f"only SoftFileLock supports lifetime-based expiry", 

524 stacklevel=stacklevel, 

525 ) 

526 return None 

527 if lifetime is not None and cls._lifetime_replacements is not None: 

528 strict_lock, lease = cls._lifetime_replacements 

529 warnings.warn( 

530 f"{cls.__name__}(lifetime=...) uses age-based expiry and can overlap a live holder; " 

531 f"use {lease} for expiry or {strict_lock} for fail-closed locking", 

532 SoftFileLockLifetimeWarning, 

533 stacklevel=stacklevel, 

534 ) 

535 return lifetime 

536 

537 

538def _resolve_context_error_policy(policy: str) -> ContextErrorPolicy: 

539 if policy not in _CONTEXT_ERROR_POLICIES: 

540 msg = f"context_error_policy must be 'chain' or 'group', got {policy!r}" 

541 raise ValueError(msg) 

542 if policy == "group": # fail fast at construction rather than only when a dual failure happens to occur 

543 try: 

544 _exception_group_cls() 

545 except ImportError as exc: # pragma: no cover # only on 3.10 without the exceptiongroup backport 

546 msg = "context_error_policy='group' requires Python 3.11+ or the 'exceptiongroup' backport installed" 

547 raise ValueError(msg) from exc 

548 return cast("ContextErrorPolicy", policy) 

549 

550 

551def _resolve_close_error_policy(policy: str) -> CloseErrorPolicy: 

552 if policy not in _CLOSE_ERROR_POLICIES: 

553 msg = f"close_error_policy must be 'default', 'raise', or 'suppress', got {policy!r}" 

554 raise ValueError(msg) 

555 return cast("CloseErrorPolicy", policy) 

556 

557 

558def _resolve_preserve_lock_file(*, preserve: bool, supported: bool, cls_name: str) -> bool: 

559 # An existence lock unlinks its marker to release, so preserving the pathname would defeat unlocking. Reject the 

560 # request rather than silently ignore it, since a caller asking for a stable identity must know it cannot be kept. 

561 if preserve and not supported: 

562 msg = f"preserve_lock_file=True is not supported by {cls_name}: unlinking its marker is how it releases" 

563 raise ValueError(msg) 

564 return preserve 

565 

566 

567def _resolve_on_acquired( 

568 on_acquired: Callable[[int], None] | None, *, supported: bool, cls_name: str 

569) -> Callable[[int], None] | None: 

570 if on_acquired is None: 

571 return None 

572 # An existence lock stores protocol state in its marker, so a caller writing through the descriptor would corrupt 

573 # stale detection and ownership metadata; only native locks lend out the descriptor. 

574 if not supported: 

575 msg = f"on_acquired is not supported by {cls_name}: only native locks expose the lock descriptor" 

576 raise ValueError(msg) 

577 # A hook that fails and then also fails to release surfaces both errors as a BaseExceptionGroup. Require that class 

578 # at construction rather than at the rare moment both fail, matching how context_error_policy='group' validates. 

579 try: 

580 _exception_group_cls() 

581 except ImportError as exc: # pragma: no cover # only on 3.10 without the exceptiongroup backport 

582 msg = "on_acquired requires Python 3.11+ or the 'exceptiongroup' backport for its rollback error path" 

583 raise ValueError(msg) from exc 

584 return on_acquired 

585 

586 

587class BaseFileLock(contextlib.ContextDecorator, metaclass=FileLockMeta): # ruff:ignore[too-many-public-methods] # public config properties 

588 """ 

589 Abstract base class for a file lock object. 

590 

591 Provides the common reentrant API and state management. Subclasses implement the locking mechanism 

592 (:class:`UnixFileLock <filelock.UnixFileLock>`, :class:`WindowsFileLock <filelock.WindowsFileLock>`, 

593 :class:`SoftFileLock <filelock.SoftFileLock>`). 

594 

595 """ 

596 

597 _instances: WeakValueDictionary[str, BaseFileLock] 

598 _instances_lock: RLock 

599 _instances_under_construction: set[str] 

600 

601 #: How the cross-instance deadlock message names the conflicting holder; the async subclass says "task". 

602 _deadlock_holder_desc: str = "FileLock instance in this thread" 

603 

604 #: Whether an age-based :attr:`lifetime` lease may break this lock. Only existence locks set it (they reclaim by 

605 #: unlinking a pathname); native OS locks leave it ``False`` since a kernel lock cannot be revoked by file age. 

606 _lifetime_supported: bool = False 

607 

608 #: Strict-lock and lease replacements for a backend with legacy age-based expiry. 

609 _lifetime_replacements: tuple[str, str] | None = None 

610 

611 #: Why a backend that refuses ``lifetime`` cannot honor it, named in the warning that drops the value. 

612 _lifetime_unsupported_reason: str = "a native OS lock cannot be broken safely by file age" 

613 

614 #: Async construction adds one metaclass frame before lifetime validation. 

615 _constructor_lifetime_warning_stacklevel: int = 3 

616 

617 #: Whether :attr:`preserve_lock_file` may be ``True``. Native locks keep the pathname on release, so they support 

618 #: it; existence locks unlink their marker to release and reject it. 

619 _preserve_lock_file_supported: bool = True 

620 

621 #: Whether an :attr:`on_acquired` hook may be set. Native locks lend the descriptor out; existence locks keep 

622 #: protocol state in the marker and reject it. 

623 _on_acquired_supported: bool = True 

624 

625 #: Whether a shared instance serializes its physical acquire and release behind one gate. A backend that publishes 

626 #: several files per owner needs it; a single-file backend is atomic and leaves it off to skip the gate entirely. 

627 _serialize_transitions: bool = False 

628 

629 #: Ceiling in seconds on the jittered backoff between contended acquisition retries. ``0`` keeps the fixed 

630 #: poll cadence; a multi-file backend sets it so contending processes desynchronize instead of livelocking. 

631 _poll_backoff_cap: float = 0.0 

632 

633 def __init_subclass__(cls, **kwargs: _SubclassValue) -> None: 

634 """Give each lock subclass its own singleton registry and lock.""" 

635 super().__init_subclass__(**kwargs) 

636 cls._instances = WeakValueDictionary() 

637 cls._instances_lock = RLock() 

638 cls._instances_under_construction = set() 

639 _register_fork_class(cls) 

640 

641 @classmethod 

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

643 cls._instances = WeakValueDictionary() 

644 cls._instances_lock = RLock() 

645 cls._instances_under_construction = set() 

646 

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

648 self, 

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

650 timeout: float = -1, 

651 mode: int = _UNSET_FILE_MODE, 

652 thread_local: bool = True, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility 

653 *, 

654 blocking: bool = True, 

655 is_singleton: bool = False, 

656 poll_interval: float = 0.05, 

657 lifetime: float | None = None, 

658 context_error_policy: ContextErrorPolicy = "chain", 

659 close_error_policy: CloseErrorPolicy = "default", 

660 fallback_to_soft: bool = True, 

661 preserve_lock_file: bool = False, 

662 on_acquired: Callable[[int], None] | None = None, 

663 ) -> None: 

664 """ 

665 Create a new lock object. 

666 

667 :param lock_file: path to the file 

668 :param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in the 

669 acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it to a 

670 negative value. A timeout of 0 means that there is exactly one attempt to acquire the file lock. 

671 :param mode: file permissions for the lockfile. When not specified, the OS controls permissions via umask and 

672 default ACLs, preserving POSIX default ACL inheritance in shared directories. 

673 :param thread_local: Whether this object's internal context should be thread local or not. If this is set to 

674 ``False`` then the lock will be reentrant across threads. When ``True`` (the default), **all fields of the 

675 lock's internal context are per-thread**, including the configuration values ``poll_interval``, ``timeout``, 

676 ``blocking``, ``mode``, and ``lifetime``. Setting one of these properties from one thread does not change 

677 the value seen by another thread; threads that did not perform the write continue to see the value supplied 

678 at construction time. ``mode`` has no setter, so construction is the only place it is ever set. If you need 

679 configuration values to be visible across threads, construct the lock with ``thread_local=False``. 

680 :param blocking: whether the lock should be blocking or not 

681 :param is_singleton: If this is set to ``True`` then only one instance of this class will be created per lock 

682 file. This is useful if you want to use the lock object for reentrant locking without needing to pass the 

683 same object around. 

684 :param poll_interval: default interval for polling the lock file, in seconds. It will be used as fallback value 

685 in the acquire method, if no poll_interval value (``None``) is given. 

686 :param lifetime: for :class:`SoftFileLock`, the age in seconds after which a waiting process may delete the 

687 marker, even while its holder remains alive. This legacy expiry mode does not provide strict mutual 

688 exclusion. ``None`` (the default) disables age-based expiry. Native OS locks (:class:`FileLock`) cannot be 

689 revoked by file age and ignore a non-``None`` ``lifetime`` with a warning. 

690 :param context_error_policy: how a context manager reconciles a failure in its body with a failure while 

691 releasing on exit. ``"chain"`` (the default) keeps Python's behavior: the release error propagates with the 

692 body error in its ``__context__``. ``"group"`` raises a :class:`BaseExceptionGroup` holding the body error 

693 first and the release error second, so neither hides the other. 

694 :param close_error_policy: what to do with an ``os.close`` failure after relinquishing descriptor ownership. 

695 ``"default"`` keeps each backend's historical behavior (Unix native locks drop a FUSE/Docker ``EIO``; 

696 Windows native locks and :class:`SoftFileLock` propagate); ``"raise"`` always propagates the ``OSError``; 

697 ``"suppress"`` always ignores it. Held state is released either way. It does not affect unlock failures or 

698 lock-file deletion. 

699 :param fallback_to_soft: for :class:`UnixFileLock`, whether to switch to :class:`SoftFileLock` when the 

700 filesystem's ``flock`` returns ``ENOSYS``. ``True`` (the default) keeps the historical fallback; 

701 ``False`` fails closed, letting the ``ENOSYS`` propagate so a caller that needs kernel-enforced 

702 locking is never silently downgraded. It has no effect on Windows or :class:`SoftFileLock`. 

703 :param preserve_lock_file: for native locks (:class:`FileLock`), whether filelock promises not to unlink the 

704 lock pathname on release. ``False`` (the default) keeps each backend's cleanup: Windows removes the lock 

705 file, Unix already leaves it. ``True`` keeps a stable file identity for ACLs, auditing, and holder metadata: 

706 Windows skips its post-release unlink and Unix refuses to enter the ``ENOSYS`` soft fallback (which releases 

707 by unlinking). :class:`SoftFileLock` rejects ``True``. The promise covers filelock's own release path only; 

708 it cannot stop another process or the filesystem from removing the pathname. 

709 :param on_acquired: for native locks (:class:`FileLock`), a callable invoked with the borrowed lock descriptor 

710 once per physical acquisition, after filelock holds the native lock and finished backend initialization but 

711 before :meth:`~BaseFileLock.acquire` returns. Recursive acquisitions do not call it again. The callback may 

712 read, write, seek, truncate, or set metadata through ``os`` on the descriptor, but must not close, unlock, 

713 or take ownership of it, and filelock does not fsync its writes. If it raises, filelock releases the lock 

714 and re-raises. :class:`SoftFileLock` rejects the hook. 

715 

716 """ 

717 self._creator_pid = os.getpid() 

718 self._transition_lock = RLock() 

719 self._is_thread_local = thread_local 

720 self._is_singleton = is_singleton 

721 self._context_error_policy = context_error_policy # already validated by the metaclass 

722 self._close_error_policy = close_error_policy # already validated by the metaclass 

723 self._fallback_to_soft = fallback_to_soft 

724 self._preserve_lock_file = preserve_lock_file # already validated by the metaclass 

725 self._on_acquired = on_acquired # already validated by the metaclass 

726 

727 self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)( 

728 lock_file=os.fspath(lock_file), 

729 timeout=timeout, 

730 mode=mode, 

731 blocking=blocking, 

732 poll_interval=poll_interval, 

733 lifetime=lifetime, 

734 ) 

735 _register_fork_object(self) 

736 

737 def is_thread_local(self) -> bool: 

738 """:returns: a flag indicating if this lock is thread local or not""" 

739 return self._is_thread_local 

740 

741 @property 

742 def is_singleton(self) -> bool: 

743 """ 

744 A flag indicating if this lock is singleton or not. 

745 

746 .. versionadded:: 3.13.0 

747 

748 """ 

749 return self._is_singleton 

750 

751 @property 

752 def context_error_policy(self) -> ContextErrorPolicy: 

753 """ 

754 How a context manager reconciles a body failure with a release failure on exit. 

755 

756 .. versionadded:: 3.30.0 

757 

758 """ 

759 return self._context_error_policy 

760 

761 @property 

762 def close_error_policy(self) -> CloseErrorPolicy: 

763 """ 

764 What a lock does with an ``os.close`` failure after relinquishing descriptor ownership. 

765 

766 .. versionadded:: 3.30.0 

767 

768 """ 

769 return self._close_error_policy 

770 

771 def _close_released_fd(self, fd: int, *, default_suppresses: bool) -> None: 

772 # CPython never retries close() after EINTR because the descriptor number may already be reused, so neither does 

773 # this. close_error_policy decides the error's fate after the backend relinquishes descriptor ownership. 

774 try: 

775 os.close(fd) 

776 except OSError: 

777 if self._close_error_policy == "suppress" or (self._close_error_policy == "default" and default_suppresses): 

778 return 

779 raise 

780 

781 @property 

782 def fallback_to_soft(self) -> bool: 

783 """ 

784 Whether a :class:`FileLock` falls back to :class:`SoftFileLock` when the filesystem lacks ``flock``. 

785 

786 Only :class:`UnixFileLock` acts on it: when ``False`` an ``ENOSYS`` from ``flock`` propagates instead of 

787 switching to existence-lock semantics. 

788 

789 .. versionadded:: 3.30.0 

790 

791 """ 

792 return self._fallback_to_soft 

793 

794 @property 

795 def preserve_lock_file(self) -> bool: 

796 """ 

797 Whether filelock promises not to unlink the lock pathname on release. 

798 

799 When ``True``, Windows skips its post-release unlink and Unix refuses the ``ENOSYS`` soft fallback. 

800 :class:`SoftFileLock` rejects ``True`` because unlinking its marker is how it releases. 

801 

802 .. versionadded:: 3.30.0 

803 

804 """ 

805 return self._preserve_lock_file 

806 

807 @property 

808 def on_acquired(self) -> Callable[[int], None] | None: 

809 """ 

810 The callback run with the borrowed lock descriptor once per physical acquisition, or ``None``. 

811 

812 Native locks only. It runs after the native lock is held and backend initialization finished, before 

813 :meth:`~BaseFileLock.acquire` returns; a raise rolls back the acquisition. :class:`SoftFileLock` rejects it. 

814 

815 .. versionadded:: 3.30.0 

816 

817 """ 

818 return self._on_acquired 

819 

820 @property 

821 def lock_file(self) -> str: 

822 """Path to the lock file.""" 

823 return self._context.lock_file 

824 

825 @property 

826 def timeout(self) -> float: 

827 """ 

828 The default timeout value, in seconds. 

829 

830 .. versionadded:: 2.0.0 

831 

832 """ 

833 return self._context.timeout 

834 

835 @timeout.setter 

836 def timeout(self, value: float | str) -> None: 

837 """ 

838 Change the default timeout value. 

839 

840 :param value: the new value, in seconds 

841 

842 """ 

843 self._context.timeout = float(value) 

844 

845 @property 

846 def blocking(self) -> bool: 

847 """ 

848 Whether the locking is blocking or not. 

849 

850 .. versionadded:: 3.14.0 

851 

852 """ 

853 return self._context.blocking 

854 

855 @blocking.setter 

856 def blocking(self, value: bool) -> None: 

857 """ 

858 Change the default blocking value. 

859 

860 :param value: the new value as bool 

861 

862 """ 

863 self._context.blocking = value 

864 

865 @property 

866 def poll_interval(self) -> float: 

867 """ 

868 The default polling interval, in seconds. 

869 

870 .. versionadded:: 3.24.0 

871 

872 """ 

873 return self._context.poll_interval 

874 

875 @poll_interval.setter 

876 def poll_interval(self, value: float) -> None: 

877 """ 

878 Change the default polling interval. 

879 

880 :param value: the new value, in seconds 

881 

882 """ 

883 self._context.poll_interval = value 

884 

885 @property 

886 def lifetime(self) -> float | None: 

887 """ 

888 The soft marker age in seconds that permits expiry, or ``None`` to disable age-based expiry. 

889 

890 A non-``None`` value permits a waiter to enter while the previous holder remains active, so it does not provide 

891 strict mutual exclusion. Native locks ignore the value with a warning. 

892 

893 .. versionadded:: 3.24.0 

894 

895 """ 

896 return self._context.lifetime 

897 

898 @lifetime.setter 

899 def lifetime(self, value: float | None) -> None: 

900 """ 

901 Change the legacy age-based expiry threshold. 

902 

903 :param value: the new value in seconds, or ``None`` to disable expiration 

904 

905 :raises ValueError: if *value* is negative or not finite 

906 :raises TypeError: if *value* is not ``None`` and not a real number 

907 

908 """ 

909 self._context.lifetime = _resolve_lifetime(value, type(self), stacklevel=3) 

910 

911 @property 

912 def mode(self) -> int: 

913 """The file permissions for the lockfile.""" 

914 return 0o644 if self._context.mode == _UNSET_FILE_MODE else self._context.mode 

915 

916 @property 

917 def has_explicit_mode(self) -> bool: 

918 """Whether the file permissions were explicitly set.""" 

919 return self._context.mode != _UNSET_FILE_MODE 

920 

921 def _open_mode(self) -> int: 

922 """Mode for ``os.open``: 0o666 when unset so umask and ACLs decide, otherwise the explicit mode.""" 

923 return 0o666 if self._context.mode == _UNSET_FILE_MODE else self._context.mode 

924 

925 @property 

926 def is_locked(self) -> bool: 

927 """ 

928 A boolean indicating if the lock file is holding the lock currently. 

929 

930 .. versionchanged:: 2.0.0 

931 

932 This was previously a method and is now a property. 

933 

934 """ 

935 _ensure_current_process() 

936 return self._context.lock_file_fd is not None 

937 

938 @property 

939 def lock_counter(self) -> int: 

940 """The number of times this lock has been acquired (but not yet released).""" 

941 _ensure_current_process() 

942 return self._context.lock_counter 

943 

944 def __enter__(self) -> Self: 

945 """ 

946 Acquire the lock. 

947 

948 :returns: the lock object 

949 

950 """ 

951 self.acquire() 

952 return self 

953 

954 def __exit__( 

955 self, 

956 exc_type: type[BaseException] | None, 

957 exc_value: BaseException | None, 

958 traceback: TracebackType | None, 

959 ) -> None: 

960 """Release the lock, reconciling a release failure with any body failure per :attr:`context_error_policy`.""" 

961 self._release_in_context(exc_value) 

962 

963 def _release_in_context(self, body_error: BaseException | None) -> None: 

964 # Release from a context-manager exit. "chain" lets a release failure propagate with the body error already in 

965 # its __context__ (Python's default); "group" raises both as sibling leaves so neither one hides the other. 

966 try: 

967 self.release() 

968 except BaseException as release_error: 

969 if body_error is None or self._context_error_policy == "chain": 

970 raise 

971 _raise_body_and_release(body_error, release_error) 

972 

973 def __del__(self) -> None: 

974 """Force-release so a dropped reference never leaks a held lock.""" 

975 if vars(self).get("_creator_pid") != os.getpid(): 

976 return # pragma: forked child 

977 # A finalizer must not raise. A release error during garbage collection would otherwise surface as an 

978 # unraisable-exception warning, attributed to whichever code triggered collection. The dropped lock still gets 

979 # best-effort cleanup; an explicit release() reports the same error to a caller who can act on it. 

980 with contextlib.suppress(Exception): 

981 self.release(force=True) 

982 

983 def acquire( 

984 self, 

985 timeout: float | None = None, 

986 poll_interval: float | None = None, 

987 *, 

988 poll_intervall: float | None = None, 

989 blocking: bool | None = None, 

990 cancel_check: Callable[[], bool] | None = None, 

991 ) -> AcquireReturnProxy: 

992 """ 

993 Try to acquire the file lock. 

994 

995 :param timeout: maximum wait time for acquiring the lock, ``None`` means use the default :attr:`~timeout` is and 

996 if ``timeout < 0``, there is no timeout and this method will block until the lock could be acquired 

997 :param poll_interval: interval of trying to acquire the lock file, ``None`` means use the default 

998 :attr:`~poll_interval` 

999 :param poll_intervall: deprecated, kept for backwards compatibility, use ``poll_interval`` instead 

1000 :param blocking: defaults to True. If False, function will return immediately if it cannot obtain a lock on the 

1001 first attempt. Otherwise, this method will block until the timeout expires or the lock is acquired. 

1002 :param cancel_check: a callable returning ``True`` when the acquisition should be canceled. Checked on each poll 

1003 iteration. When triggered, raises :class:`~Timeout` just like an expired timeout. 

1004 

1005 :returns: a context object that will unlock the file when the context is exited 

1006 

1007 :raises Timeout: if fails to acquire lock within the timeout period 

1008 

1009 .. code-block:: python 

1010 

1011 # You can use this method in the context manager (recommended) 

1012 with lock.acquire(): 

1013 pass 

1014 

1015 # Or use an equivalent try-finally construct: 

1016 lock.acquire() 

1017 try: 

1018 pass 

1019 finally: 

1020 lock.release() 

1021 

1022 .. versionchanged:: 2.0.0 

1023 

1024 This method returns now a *proxy* object instead of *self*, so that it can be used in a with statement 

1025 without side effects. 

1026 

1027 """ 

1028 self._raise_if_inherited() 

1029 if timeout is None: 

1030 timeout = self._context.timeout 

1031 

1032 if blocking is None: 

1033 blocking = self._context.blocking 

1034 

1035 if poll_intervall is not None: 

1036 msg = "use poll_interval instead of poll_intervall" 

1037 warnings.warn(msg, DeprecationWarning, stacklevel=2) 

1038 poll_interval = poll_intervall 

1039 

1040 poll_interval = poll_interval if poll_interval is not None else self._context.poll_interval 

1041 

1042 start_time = time.perf_counter() 

1043 # Wait for admission before touching any state: a caller refused entry must leave the counter, the registry and 

1044 # the descriptor exactly as it found them. 

1045 with self._transition_admission( 

1046 blocking=blocking, 

1047 cancel_check=cancel_check, 

1048 timeout=timeout, 

1049 poll_interval=poll_interval, 

1050 start_time=start_time, 

1051 ): 

1052 # Bump the counter up front; _undo_acquire rolls it back if acquisition fails. 

1053 self._context.lock_counter += 1 

1054 

1055 canonical = _canonical(self.lock_file) 

1056 self._raise_if_would_deadlock(canonical, timeout=timeout, blocking=blocking) 

1057 

1058 try: 

1059 self._poll_until_acquired( 

1060 blocking=blocking, 

1061 cancel_check=cancel_check, 

1062 timeout=timeout, 

1063 poll_interval=poll_interval, 

1064 start_time=start_time, 

1065 ) 

1066 except BaseException: 

1067 self._reconcile_failed_acquire(canonical) 

1068 raise 

1069 self._commit_acquire(canonical) 

1070 return AcquireReturnProxy(lock=self) 

1071 

1072 @contextlib.contextmanager 

1073 def _transition_admission( 

1074 self, 

1075 *, 

1076 blocking: bool, 

1077 cancel_check: Callable[[], bool] | None, 

1078 timeout: float, 

1079 poll_interval: float, 

1080 start_time: float, 

1081 ) -> Generator[None]: 

1082 # One thread at a time drives the physical transition of a shared instance. A protocol that publishes several 

1083 # files per owner leaves a half-built claim visible otherwise, and a second thread would read it as a holder. 

1084 # Only such a backend opts in; a single-file backend and a thread-local context each need no gate. 

1085 if not self._serialize_transitions or self._is_thread_local: 

1086 yield 

1087 return 

1088 while not self._transition_lock.acquire(blocking=False): # pragma: needs hard-link 

1089 if not blocking or (cancel_check is not None and cancel_check()): 

1090 raise Timeout(self.lock_file) 

1091 if timeout >= 0 and time.perf_counter() - start_time >= timeout: 

1092 raise Timeout(self.lock_file) 

1093 time.sleep(poll_interval) 

1094 try: # pragma: needs hard-link 

1095 yield 

1096 finally: # pragma: needs hard-link 

1097 self._transition_lock.release() 

1098 

1099 def release(self, force: bool = False) -> None: # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility 

1100 """ 

1101 Release the file lock. The lock is only completely released when the lock counter reaches 0. The lock file 

1102 itself may be deleted automatically, the behavior is platform-specific. 

1103 

1104 :param force: If true, the lock counter is ignored and the lock is released in every case. 

1105 

1106 """ 

1107 # A shared instance releases under the same gate its acquisition ran through, so a thread entering the lock 

1108 # never observes a partially torn-down owner. 

1109 serialize = self._serialize_transitions and not self._is_thread_local 

1110 with self._transition_lock if serialize else contextlib.nullcontext(): 

1111 if self._creator_pid != os.getpid() or not self.is_locked: 

1112 return 

1113 if not force and self._context.lock_counter > 1: 

1114 self._context.lock_counter -= 1 

1115 return 

1116 

1117 lock_id, lock_filename = id(self), self.lock_file 

1118 _LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename) 

1119 try: 

1120 self._release_with_fork_tracking() 

1121 except BaseException: 

1122 # A failure after the OS unlock (during close or unlink) still released the lock: the backend cleared 

1123 # its descriptor, so commit the counter and registry to released even as the cleanup error propagates. 

1124 # A failure that left the lock held keeps the counter so a later release can retry the OS unlock. 

1125 if not self.is_locked: 

1126 self._commit_release() 

1127 raise 

1128 self._commit_release() 

1129 _LOGGER.debug("Lock %s released on %s", lock_id, lock_filename) 

1130 

1131 def _raise_if_inherited(self) -> None: 

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

1133 msg = f"{type(self).__name__} on {self.lock_file} was inherited across fork; construct a new instance" 

1134 raise RuntimeError(msg) 

1135 

1136 def _mark_descriptor_owned(self, fd: int, identity: tuple[int, int] | None = None) -> None: 

1137 self._context.pending_lock_file_fd = None 

1138 self._context.pending_lock_file_fd_identity = None 

1139 self._context.lock_file_fd = fd 

1140 self._context.lock_file_fd_identity = identity 

1141 

1142 def _mark_descriptor_pending(self, fd: int, identity: tuple[int, int] | None = None) -> None: 

1143 self._context.pending_lock_file_fd = fd 

1144 self._context.pending_lock_file_fd_identity = identity 

1145 

1146 def _mark_descriptor_released(self) -> None: 

1147 self._context.pending_lock_file_fd = None 

1148 self._context.pending_lock_file_fd_identity = None 

1149 self._context.lock_file_fd = None 

1150 self._context.lock_file_fd_identity = None 

1151 

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

1153 # fork copies the lock in whatever state the parent's threads left it, so give the child an unheld one. 

1154 self._transition_lock = RLock() 

1155 self._context.owner_claim_paths = () 

1156 self._context.claim_root = None 

1157 self._context.lock_file_fd = None 

1158 self._context.lock_file_fd_token = None 

1159 self._context.lock_file_fd_identity = None 

1160 self._context.pending_lock_file_fd = None 

1161 self._context.pending_lock_file_fd_identity = None 

1162 self._context.lock_counter = 0 

1163 self._context.lock_file_key = None 

1164 

1165 def _descriptors_for_fork(self) -> tuple[tuple[int, tuple[int, int] | None], ...]: # pragma: needs fork 

1166 descriptors: list[tuple[int, tuple[int, int] | None]] = [] 

1167 if self._context.lock_file_fd is not None and self._context.lock_file_fd_token is None: 

1168 descriptors.append((self._context.lock_file_fd, self._context.lock_file_fd_identity)) 

1169 if self._context.pending_lock_file_fd is not None: 

1170 descriptors.append((self._context.pending_lock_file_fd, self._context.pending_lock_file_fd_identity)) 

1171 return tuple(descriptors) 

1172 

1173 def _raise_if_would_deadlock(self, canonical: str, *, timeout: float, blocking: bool) -> None: 

1174 """ 

1175 Fail fast when a *different* live instance already holds this path in the current deadlock scope. 

1176 

1177 Only the first, indefinitely-blocking acquire can self-deadlock this way: waiting in the OS primitive would 

1178 block on a lock this flow already owns. A finite timeout or ``blocking=False`` keeps the normal Timeout path. 

1179 """ 

1180 would_block = self._context.lock_counter == 1 and not self.is_locked and timeout < 0 and blocking 

1181 if would_block and _registry.held.get(self._registry_key(canonical)) not in {None, id(self)}: 

1182 self._context.lock_counter -= 1 

1183 msg = ( 

1184 f"Deadlock: lock '{self.lock_file}' is already held by a different {self._deadlock_holder_desc}. " 

1185 f"Use is_singleton=True to enable reentrant locking across instances." 

1186 ) 

1187 raise RuntimeError(msg) 

1188 

1189 def _registry_key(self, canonical: str) -> Hashable: 

1190 return canonical if (scope := self._deadlock_scope()) is None else (scope, canonical) 

1191 

1192 @staticmethod 

1193 def _deadlock_scope() -> Hashable | None: 

1194 """ 

1195 Execution unit whose own hold would deadlock a new acquire. 

1196 

1197 ``None`` scopes holders to the thread, which the thread-local registry already separates. Async locks 

1198 override it with the running task, since one event loop thread runs many tasks and only a reacquire from 

1199 the *same* task can self-deadlock. 

1200 """ 

1201 return None 

1202 

1203 def _poll_until_acquired( 

1204 self, 

1205 *, 

1206 blocking: bool, 

1207 cancel_check: Callable[[], bool] | None, 

1208 timeout: float, 

1209 poll_interval: float, 

1210 start_time: float, 

1211 ) -> None: 

1212 lock_id = id(self) 

1213 lock_filename = self.lock_file 

1214 attempt = 0 

1215 while True: 

1216 self._raise_if_inherited() 

1217 if not self.is_locked: 

1218 self._try_break_expired_lock() 

1219 _LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) 

1220 self._acquire_with_fork_tracking() 

1221 self._raise_if_inherited() 

1222 if self.is_locked: 

1223 _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename) 

1224 return 

1225 if self._check_give_up( 

1226 blocking=blocking, 

1227 cancel_check=cancel_check, 

1228 timeout=timeout, 

1229 start_time=start_time, 

1230 ): 

1231 raise Timeout(lock_filename) 

1232 attempt += 1 

1233 delay = self._poll_delay(poll_interval, attempt) 

1234 msg = "Lock %s not acquired on %s, waiting %s seconds ..." 

1235 _LOGGER.debug(msg, lock_id, lock_filename, delay) 

1236 time.sleep(delay) 

1237 

1238 def _poll_delay(self, poll_interval: float, attempt: int) -> float: 

1239 # A single-file lock retries on a fixed cadence. A backend that publishes several files per acquisition sets a 

1240 # cap, and then contending processes back off across a jittered, exponentially widening window instead of 

1241 # colliding on every poll; poll_interval stays the floor so a lone waiter is still responsive. 

1242 if not self._poll_backoff_cap: 

1243 return poll_interval 

1244 # Cap the exponent before doubling: under heavy contention attempt reaches the thousands, and 2**attempt would 

1245 # overflow the float multiply long before the window itself stops growing past the cap. 

1246 window = min( 

1247 self._poll_backoff_cap, poll_interval * 2 ** min(attempt, _MAX_BACKOFF_EXPONENT) 

1248 ) # pragma: needs hard-link 

1249 return max(poll_interval, secrets.randbelow(int(window * 1_000_000) + 1) / 1_000_000) # pragma: needs hard-link 

1250 

1251 def _reconcile_failed_acquire(self, canonical: str) -> None: 

1252 # An acquire that raised while still holding the native lock (a hook that failed and whose rollback could not 

1253 # release) must keep the registry entry so a later release can retry the OS unlock; otherwise roll the counter 

1254 # back. is_locked was already reconciled by whichever release ran. 

1255 if self.is_locked: 

1256 self._commit_acquire(canonical) 

1257 else: 

1258 self._undo_acquire() 

1259 

1260 def _invoke_on_acquired(self) -> None: 

1261 # The wrapper runs in the backend executor for async locks, preserving the callback's documented thread. 

1262 if self._on_acquired is None or self._context.lock_counter != 1: 

1263 return 

1264 try: 

1265 self._on_acquired(cast("int", self._context.lock_file_fd)) 

1266 except BaseException as callback_error: # arbitrary caller code; roll back on any failure 

1267 callback_context = callback_error.__context__ 

1268 try: 

1269 self._release_with_fork_tracking() 

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

1271 _raise_body_and_release(callback_error, release_error) 

1272 callback_error.__context__ = callback_context 

1273 raise 

1274 

1275 def _acquire_with_fork_tracking(self) -> None: 

1276 with _fork_transition(self): 

1277 try: 

1278 self._acquire() 

1279 except BaseException as acquisition_error: 

1280 self._rollback_failed_acquire(acquisition_error) 

1281 raise 

1282 try: 

1283 self._register_context_descriptor() 

1284 except BaseException as registration_error: # pragma: needs fork 

1285 self._rollback_failed_registration(registration_error) 

1286 raise 

1287 if self.is_locked: 

1288 self._invoke_on_acquired() 

1289 

1290 def _rollback_failed_acquire(self, acquisition_error: BaseException) -> None: 

1291 if not self.is_locked: 

1292 return 

1293 registration_error: BaseException | None = None 

1294 tracking_error: BaseException | None = None 

1295 try: 

1296 self._register_context_descriptor() 

1297 except BaseException as error: # ruff:ignore[blind-except] # preserve registration and acquisition failures 

1298 registration_error = error 

1299 try: 

1300 # Rollback may fail too; retain the fd so a child can close it without another identity probe. 

1301 self._register_unverified_context_descriptor() 

1302 except BaseException as error: # ruff:ignore[blind-except] # pragma: no cover - allocation/control-flow during fallback 

1303 tracking_error = error 

1304 try: 

1305 self._release_with_fork_tracking() 

1306 except BaseException as rollback_error: # ruff:ignore[blind-except] # preserve rollback and acquisition failures 

1307 _raise_cleanup_errors( 

1308 "lock acquisition cleanup failed", 

1309 acquisition_error, 

1310 registration_error, 

1311 tracking_error, 

1312 rollback_error, 

1313 ) 

1314 if registration_error is not None: # pragma: needs fork 

1315 _raise_cleanup_errors( 

1316 "lock acquisition cleanup failed", acquisition_error, registration_error, tracking_error 

1317 ) 

1318 

1319 def _rollback_failed_registration(self, registration_error: BaseException) -> None: # pragma: needs fork 

1320 tracking_error: BaseException | None = None 

1321 try: 

1322 # Rollback may fail too; retain the fd so a child can close it without another identity probe. 

1323 self._register_unverified_context_descriptor() 

1324 except BaseException as error: # ruff:ignore[blind-except] # pragma: no cover - allocation/control-flow during fallback 

1325 tracking_error = error 

1326 try: 

1327 self._release_with_fork_tracking() 

1328 except BaseException as rollback_error: # ruff:ignore[blind-except] # preserve rollback and registration failures 

1329 _raise_cleanup_errors( 

1330 "descriptor registration cleanup failed", registration_error, tracking_error, rollback_error 

1331 ) 

1332 if tracking_error is not None: # pragma: no cover - requires failed in-memory fallback 

1333 _raise_cleanup_errors("descriptor registration cleanup failed", registration_error, tracking_error) 

1334 

1335 def _release_with_fork_tracking(self) -> None: 

1336 with _fork_transition(self): 

1337 try: 

1338 self._release() 

1339 finally: 

1340 self._unregister_released_descriptor() 

1341 

1342 def _register_context_descriptor(self) -> None: 

1343 if self._context.lock_file_fd is not None and self._context.lock_file_fd_token is None: 

1344 self._context.lock_file_fd_token = _register_owned_descriptor( 

1345 self._context.lock_file_fd, 

1346 self._context.lock_file_fd_identity, 

1347 ) 

1348 

1349 def _register_unverified_context_descriptor(self) -> None: 

1350 # The rollback only reaches here still holding a descriptor it never managed to register. 

1351 if self._context.lock_file_fd is not None and self._context.lock_file_fd_token is None: # pragma: no branch 

1352 self._context.lock_file_fd_token = _register_unverified_owned_descriptor(self._context.lock_file_fd) 

1353 

1354 def _unregister_released_descriptor(self) -> None: 

1355 if self._context.lock_file_fd is None: 

1356 if (token := self._context.lock_file_fd_token) is not None: # pragma: needs fork 

1357 _unregister_owned_descriptor(token) 

1358 self._context.lock_file_fd_token = None 

1359 self._context.lock_file_fd_identity = None 

1360 

1361 def _undo_acquire(self) -> None: 

1362 """Roll back the counter after a failed acquire, dropping the registry entry once nothing holds the path.""" 

1363 self._context.lock_counter = max(0, self._context.lock_counter - 1) 

1364 if self._context.lock_counter == 0: 

1365 self._drop_registry_entry() 

1366 

1367 def _commit_acquire(self, canonical: str) -> None: 

1368 """Record this instance as the holder once the first acquire succeeds, so peers can detect the deadlock.""" 

1369 if self._context.lock_counter == 1: 

1370 key = self._registry_key(canonical) 

1371 # The holder scope is resolved once at commit so a later release from another flow drops the right entry. 

1372 self._context.lock_file_key = key 

1373 _registry.held[key] = id(self) 

1374 

1375 def _drop_registry_entry(self) -> None: 

1376 """Forget the key owned by this hold without resolving a mutable path again.""" 

1377 key = self._context.lock_file_key 

1378 self._context.lock_file_key = None 

1379 if key is not None: 

1380 _registry.held.pop(key, None) 

1381 

1382 def _commit_release(self) -> None: 

1383 """Record the lock as fully released: reset the recursion counter and drop the deadlock-registry entry.""" 

1384 self._context.lock_counter = 0 

1385 self._drop_registry_entry() 

1386 

1387 def _try_break_expired_lock(self) -> None: 

1388 """Remove the lock file if its modification time exceeds the configured :attr:`lifetime`.""" 

1389 if (lifetime := self._context.lifetime) is None: 

1390 return 

1391 with contextlib.suppress(OSError): 

1392 # lstat, not stat: an attacker with write access to the lock directory can replace a held 

1393 # lock file with a symlink pointing at an old file, making stat() report the target's stale 

1394 # mtime so a waiter breaks a live lock and two processes hold it at once. lstat reads the 

1395 # symlink's own mtime, matching the O_NOFOLLOW reads elsewhere. 

1396 st = os.lstat(self.lock_file) 

1397 if time.time() - st.st_mtime < lifetime: 

1398 return 

1399 break_lock_file(self.lock_file, st.st_mtime, st.st_ino) 

1400 

1401 def _check_give_up( 

1402 self, 

1403 *, 

1404 blocking: bool, 

1405 cancel_check: Callable[[], bool] | None, 

1406 timeout: float, 

1407 start_time: float, 

1408 ) -> bool: 

1409 lock_id, lock_filename = id(self), self.lock_file 

1410 if blocking is False: 

1411 _LOGGER.debug("Failed to immediately acquire lock %s on %s", lock_id, lock_filename) 

1412 return True 

1413 if cancel_check is not None and cancel_check(): 

1414 _LOGGER.debug("Cancellation requested for lock %s on %s", lock_id, lock_filename) 

1415 return True 

1416 if 0 <= timeout < time.perf_counter() - start_time: 

1417 _LOGGER.debug("Timeout on acquiring lock %s on %s", lock_id, lock_filename) 

1418 return True 

1419 return False 

1420 

1421 @abstractmethod 

1422 def _acquire(self) -> None: 

1423 """If the file lock could be acquired, self._context.lock_file_fd holds the file descriptor of the lock file.""" 

1424 raise NotImplementedError 

1425 

1426 @abstractmethod 

1427 def _release(self) -> None: 

1428 """Releases the lock and sets self._context.lock_file_fd to None.""" 

1429 raise NotImplementedError 

1430 

1431 

1432# acquire() returns this wrapper instead of self so entering the with-statement does not call __enter__ a second 

1433# time; returning self would re-acquire the lock in BaseFileLock.__enter__ without a matching release (issue #37). 

1434class AcquireReturnProxy: 

1435 """A context-aware object that will release the lock file when exiting.""" 

1436 

1437 def __init__(self, lock: BaseFileLock | ReadWriteLock | SoftReadWriteLock) -> None: 

1438 self.lock: BaseFileLock | ReadWriteLock | SoftReadWriteLock = lock 

1439 

1440 def __enter__(self) -> BaseFileLock | ReadWriteLock | SoftReadWriteLock: 

1441 return self.lock 

1442 

1443 def __exit__( 

1444 self, 

1445 exc_type: type[BaseException] | None, 

1446 exc_value: BaseException | None, 

1447 traceback: TracebackType | None, 

1448 ) -> None: 

1449 if isinstance(self.lock, BaseFileLock): 

1450 self.lock._release_in_context(exc_value) # ruff:ignore[private-member-access] # forwards __exit__ to the owned lock's context release 

1451 else: # a reader/writer lock does not carry a context_error_policy 

1452 self.lock.release() 

1453 

1454 

1455@dataclass 

1456class FileLockContext: 

1457 """Holds the context for a ``BaseFileLock`` object.""" 

1458 

1459 # A separate class so ThreadLocalFileContext can make the whole context thread-local. 

1460 

1461 lock_file: str 

1462 timeout: float 

1463 mode: int 

1464 blocking: bool 

1465 poll_interval: float 

1466 

1467 #: The lock lifetime in seconds; ``None`` means the lock never expires. 

1468 lifetime: float | None = None 

1469 

1470 #: File descriptor from os.open for the lock file; not None while the lock is held. 

1471 lock_file_fd: int | None = None 

1472 

1473 #: Registry token for the descriptor owned by this thread's context. 

1474 lock_file_fd_token: int | None = None 

1475 

1476 #: Identity captured by a backend that already inspected the descriptor. 

1477 lock_file_fd_identity: tuple[int, int] | None = None 

1478 

1479 #: Descriptor opened by a backend but not yet committed as the held lock. 

1480 pending_lock_file_fd: int | None = None 

1481 

1482 #: Identity captured for a descriptor whose acquisition has not committed. 

1483 pending_lock_file_fd_identity: tuple[int, int] | None = None 

1484 

1485 #: Depth of nested acquisitions; the lock is released only when it returns to 0. 

1486 lock_counter: int = 0 

1487 

1488 #: Canonical registry key captured when the first physical acquisition commits. 

1489 lock_file_key: Hashable | None = None 

1490 

1491 #: Claim pathnames this owner published, removed by name on release so no holder ever unlinks a peer's claim. 

1492 owner_claim_paths: tuple[str, ...] = () 

1493 

1494 #: Canonical lock path resolved when an acquisition starts. A waiter polling a relative path must keep publishing 

1495 #: into the directory it started in, even when another thread changes the working directory mid-wait. 

1496 claim_root: str | None = None 

1497 

1498 

1499class ThreadLocalFileContext(FileLockContext, local): 

1500 """A thread local version of the ``FileLockContext`` class.""" 

1501 

1502 

1503@dataclass(frozen=True) 

1504class _OwnedDescriptor: 

1505 fd: int 

1506 creator_pid: int 

1507 device: int | None 

1508 inode: int | None 

1509 

1510 

1511class _ForkTransitionContext(local): 

1512 depth: int = 0 

1513 

1514 

1515class _ForkState: 

1516 def __init__(self) -> None: 

1517 self.gate = Condition(RLock()) 

1518 self.registry_lock = RLock() 

1519 self.parameter_models_lock = RLock() 

1520 self.transition_context = _ForkTransitionContext() 

1521 self.transitions: dict[int, dict[int, _ForkDescriptorOwner | None]] = {} 

1522 self.active_transitions = 0 

1523 self.admission_closed = False 

1524 self.fork_owner_depths: dict[int, int] = {} 

1525 self.pinned_objects: dict[int, list[tuple[_ForkResettable, ...]]] = {} 

1526 self.pinned_classes: dict[int, list[tuple[_ForkResettableClass, ...]]] = {} 

1527 self.provisional_descriptor_tokens: dict[int, list[tuple[int, ...]]] = {} 

1528 self.pid = os.getpid() 

1529 

1530 def reset_synchronization(self) -> None: # pragma: forked child 

1531 self.gate = Condition(RLock()) 

1532 self.registry_lock = RLock() 

1533 self.parameter_models_lock = RLock() 

1534 self.transition_context = _ForkTransitionContext() 

1535 self.transitions = {} 

1536 self.active_transitions = 0 

1537 self.admission_closed = False 

1538 self.fork_owner_depths = {} 

1539 self.pinned_objects = {} 

1540 self.pinned_classes = {} 

1541 self.provisional_descriptor_tokens = {} 

1542 

1543 

1544_FORK_OBJECTS: Final[WeakValueDictionary[int, _ForkResettable]] = WeakValueDictionary() 

1545_FORK_CLASSES: Final[WeakValueDictionary[int, _ForkResettableClass]] = WeakValueDictionary() 

1546_OWNED_DESCRIPTORS: Final[dict[int, _OwnedDescriptor]] = {} 

1547_DESCRIPTOR_TOKENS: Final[count[int]] = count() 

1548_TRANSITION_TOKENS: Final[count[int]] = count() 

1549_FORK_STATE: Final = _ForkState() 

1550_FORK_AUDIT_EVENTS: Final[frozenset[str]] = frozenset({"os.fork", "os.forkpty"}) 

1551 

1552 

1553def _register_fork_hooks() -> None: 

1554 if _REGISTER_AT_FORK is None: 

1555 return # pragma: lacks fork 

1556 sys.addaudithook(_audit_fork_safety) # pragma: needs fork 

1557 _REGISTER_AT_FORK( # pragma: needs fork 

1558 before=_pin_fork_objects, 

1559 after_in_parent=_resume_parent_after_fork, 

1560 after_in_child=_reset_child_after_fork, 

1561 ) 

1562 

1563 

1564@contextmanager 

1565def _fork_transition(descriptor_owner: _ForkDescriptorOwner | None = None) -> Generator[None]: 

1566 if not _HAS_REGISTER_AT_FORK: 

1567 yield # pragma: lacks fork 

1568 return # pragma: lacks fork 

1569 _ensure_current_process() # pragma: needs fork 

1570 creator_pid = os.getpid() # pragma: needs fork 

1571 token = _enter_fork_transition(descriptor_owner) # pragma: needs fork 

1572 try: # pragma: needs fork 

1573 yield 

1574 finally: 

1575 if os.getpid() == creator_pid: # pragma: needs fork 

1576 _leave_fork_transition(token) 

1577 

1578 

1579def _register_fork_object(instance: _ForkResettable) -> None: 

1580 if not _HAS_REGISTER_AT_FORK: 

1581 return # pragma: lacks fork 

1582 with _fork_transition(), _FORK_STATE.registry_lock: # pragma: needs fork 

1583 _FORK_OBJECTS[id(instance)] = instance 

1584 _refresh_owner_pins() 

1585 

1586 

1587def _register_fork_class(cls: _ForkResettableClass) -> None: 

1588 if not _HAS_REGISTER_AT_FORK: 

1589 return # pragma: lacks fork 

1590 with _fork_transition(), _FORK_STATE.registry_lock: # pragma: needs fork 

1591 _FORK_CLASSES[id(cls)] = cls 

1592 _refresh_owner_pins() 

1593 

1594 

1595def _register_owned_descriptor(fd: int, identity: tuple[int, int] | None = None) -> int | None: 

1596 if not _HAS_REGISTER_AT_FORK: 

1597 return None # pragma: lacks fork 

1598 with _fork_transition(): # pragma: needs fork 

1599 if identity is None: 

1600 stat_result = os.fstat(fd) 

1601 identity = stat_result.st_dev, stat_result.st_ino 

1602 return _record_owned_descriptor(fd, identity) 

1603 

1604 

1605def _register_unverified_owned_descriptor(fd: int) -> int | None: 

1606 if not _HAS_REGISTER_AT_FORK: 

1607 return None # pragma: lacks fork 

1608 with _fork_transition(): # pragma: needs fork 

1609 return _record_owned_descriptor(fd, None) 

1610 

1611 

1612def _record_owned_descriptor(fd: int, identity: tuple[int, int] | None) -> int: # pragma: needs fork 

1613 with _FORK_STATE.registry_lock: 

1614 token = next(_DESCRIPTOR_TOKENS) 

1615 _OWNED_DESCRIPTORS[token] = _OwnedDescriptor( 

1616 fd=fd, 

1617 creator_pid=os.getpid(), 

1618 device=None if identity is None else identity[0], 

1619 inode=None if identity is None else identity[1], 

1620 ) 

1621 return token 

1622 

1623 

1624def _unregister_owned_descriptor(token: int) -> None: # pragma: needs fork 

1625 with _fork_transition(), _FORK_STATE.registry_lock: 

1626 _OWNED_DESCRIPTORS.pop(token, None) 

1627 

1628 

1629def _pin_fork_objects() -> None: # pragma: needs fork 

1630 _ensure_current_process() 

1631 thread_id = get_ident() 

1632 with _FORK_STATE.gate: 

1633 _FORK_STATE.fork_owner_depths[thread_id] = _FORK_STATE.fork_owner_depths.get(thread_id, 0) + 1 

1634 _FORK_STATE.admission_closed = True 

1635 while _FORK_STATE.active_transitions > len(_FORK_STATE.transitions.get(thread_id, ())): 

1636 _FORK_STATE.gate.wait() 

1637 transition_owners = tuple(_FORK_STATE.transitions.get(thread_id, {}).values()) 

1638 _verify_unverified_descriptors() 

1639 owners = {id(owner): owner for owner in transition_owners if owner is not None} 

1640 provisional_descriptor_tokens = tuple( 

1641 starmap( 

1642 _snapshot_descriptor_for_fork, 

1643 (descriptor for owner in owners.values() for descriptor in owner._descriptors_for_fork()), # ruff:ignore[private-member-access] # snapshots each owner's own fork descriptors 

1644 ) 

1645 ) 

1646 with _FORK_STATE.registry_lock: 

1647 _FORK_STATE.provisional_descriptor_tokens.setdefault(thread_id, []).append(provisional_descriptor_tokens) 

1648 _FORK_STATE.pinned_objects.setdefault(thread_id, []).append(tuple(_FORK_OBJECTS.values())) 

1649 _FORK_STATE.pinned_classes.setdefault(thread_id, []).append(tuple(_FORK_CLASSES.values())) 

1650 

1651 

1652def _resume_parent_after_fork() -> None: # pragma: needs fork 

1653 thread_id = get_ident() 

1654 with _FORK_STATE.registry_lock: 

1655 for token in _FORK_STATE.provisional_descriptor_tokens[thread_id].pop(): 

1656 _OWNED_DESCRIPTORS.pop(token, None) 

1657 _FORK_STATE.pinned_objects[thread_id].pop() 

1658 _FORK_STATE.pinned_classes[thread_id].pop() 

1659 if not _FORK_STATE.provisional_descriptor_tokens[thread_id]: # pragma: needs fork 

1660 del _FORK_STATE.provisional_descriptor_tokens[thread_id] 

1661 del _FORK_STATE.pinned_objects[thread_id] 

1662 del _FORK_STATE.pinned_classes[thread_id] 

1663 with _FORK_STATE.gate: 

1664 if _FORK_STATE.fork_owner_depths[thread_id] == 1: 

1665 del _FORK_STATE.fork_owner_depths[thread_id] 

1666 else: # pragma: no cover - earlier at-fork callbacks may deadlock first 

1667 _FORK_STATE.fork_owner_depths[thread_id] -= 1 

1668 _FORK_STATE.admission_closed = bool(_FORK_STATE.fork_owner_depths) 

1669 _FORK_STATE.gate.notify_all() 

1670 

1671 

1672def _ensure_current_process() -> None: 

1673 _reset_child_after_fork() 

1674 

1675 

1676def _reset_child_after_fork() -> None: # pragma: forked child 

1677 if (pid := os.getpid()) == _FORK_STATE.pid: 

1678 return 

1679 thread_id = get_ident() 

1680 pinned_objects = _FORK_STATE.pinned_objects.get(thread_id, [()])[-1] 

1681 pinned_classes = _FORK_STATE.pinned_classes.get(thread_id, [()])[-1] 

1682 descriptors: list[_OwnedDescriptor] = [] 

1683 for token, descriptor in tuple(_OWNED_DESCRIPTORS.items()): 

1684 if descriptor.creator_pid != pid: 

1685 descriptors.append(_OWNED_DESCRIPTORS.pop(token)) 

1686 _FORK_STATE.reset_synchronization() 

1687 _FORK_STATE.pid = pid 

1688 _INIT_PARAMETER_MODELS.clear() 

1689 _detach_child_state(descriptors, pinned_objects, pinned_classes) 

1690 

1691 

1692def _enter_fork_transition(descriptor_owner: _ForkDescriptorOwner | None) -> int: # pragma: needs fork 

1693 thread_id = get_ident() 

1694 with _FORK_STATE.gate: 

1695 while ( 

1696 _FORK_STATE.admission_closed 

1697 and thread_id not in _FORK_STATE.fork_owner_depths 

1698 and thread_id not in _FORK_STATE.transitions 

1699 ): 

1700 _FORK_STATE.gate.wait() # pragma: no cover - exercised in isolated interpreter 

1701 token = next(_TRANSITION_TOKENS) 

1702 _FORK_STATE.transitions.setdefault(thread_id, {})[token] = descriptor_owner 

1703 _FORK_STATE.active_transitions += 1 

1704 _FORK_STATE.transition_context.depth += 1 

1705 return token 

1706 

1707 

1708def _leave_fork_transition(token: int) -> None: # pragma: needs fork 

1709 _FORK_STATE.transition_context.depth -= 1 

1710 with _FORK_STATE.gate: 

1711 thread_id = get_ident() 

1712 del _FORK_STATE.transitions[thread_id][token] 

1713 if not _FORK_STATE.transitions[thread_id]: 

1714 del _FORK_STATE.transitions[thread_id] 

1715 _FORK_STATE.active_transitions -= 1 

1716 _FORK_STATE.gate.notify_all() 

1717 

1718 

1719def _verify_unverified_descriptors() -> None: # pragma: needs fork 

1720 with _FORK_STATE.registry_lock: 

1721 for token, descriptor in tuple(_OWNED_DESCRIPTORS.items()): 

1722 if descriptor.creator_pid != os.getpid() or descriptor.device is not None: 

1723 continue 

1724 try: 

1725 stat_result = os.fstat(descriptor.fd) 

1726 except OSError: 

1727 continue 

1728 _OWNED_DESCRIPTORS[token] = _OwnedDescriptor( 

1729 fd=descriptor.fd, 

1730 creator_pid=descriptor.creator_pid, 

1731 device=stat_result.st_dev, 

1732 inode=stat_result.st_ino, 

1733 ) 

1734 

1735 

1736def _snapshot_descriptor_for_fork(fd: int, identity: tuple[int, int] | None) -> int: # pragma: needs fork 

1737 if identity is None: # pragma: needs fork 

1738 try: 

1739 stat_result = os.fstat(fd) 

1740 except OSError: 

1741 pass 

1742 else: 

1743 identity = stat_result.st_dev, stat_result.st_ino 

1744 return _record_owned_descriptor(fd, identity) 

1745 

1746 

1747def _detach_child_state( # pragma: forked child 

1748 descriptors: list[_OwnedDescriptor], 

1749 pinned_objects: tuple[_ForkResettable, ...], 

1750 pinned_classes: tuple[_ForkResettableClass, ...], 

1751) -> None: 

1752 for descriptor in descriptors: 

1753 if descriptor.device is None: 

1754 continue 

1755 try: 

1756 stat_result = os.fstat(descriptor.fd) 

1757 except OSError: 

1758 continue 

1759 if (stat_result.st_dev, stat_result.st_ino) != (descriptor.device, descriptor.inode): 

1760 continue 

1761 with contextlib.suppress(OSError): 

1762 os.close(descriptor.fd) 

1763 for instance in pinned_objects: 

1764 instance._reset_after_fork_in_child() # ruff:ignore[private-member-access] # resets each pinned instance in the fork child 

1765 for cls in pinned_classes: 

1766 cls._reset_class_after_fork() 

1767 _registry.held.clear() 

1768 

1769 

1770def _refresh_owner_pins() -> None: # pragma: needs fork 

1771 with _FORK_STATE.gate: 

1772 fork_owner_thread_ids = tuple(_FORK_STATE.fork_owner_depths) 

1773 if get_ident() not in fork_owner_thread_ids: # pragma: no cover - at-fork callbacks disable tracing 

1774 return 

1775 objects = tuple(_FORK_OBJECTS.values()) 

1776 classes = tuple(_FORK_CLASSES.values()) 

1777 for thread_id in fork_owner_thread_ids: 

1778 if object_snapshots := _FORK_STATE.pinned_objects.get(thread_id): # pragma: needs fork 

1779 object_snapshots[:] = [objects] * len(object_snapshots) 

1780 _FORK_STATE.pinned_classes[thread_id][:] = [classes] * len(object_snapshots) 

1781 

1782 

1783# The defaults capture the module globals: the hook outlives them at interpreter shutdown, where CPython wipes the 

1784# module dict to None before the final audit events fire. 

1785def _audit_fork_safety( # pragma: no cover - CPython disables tracing while Python audit hooks run 

1786 event: str, 

1787 _args: Unused, 

1788 *, 

1789 _fork_events: frozenset[str] = _FORK_AUDIT_EVENTS, 

1790 _state: _ForkState = _FORK_STATE, 

1791) -> None: 

1792 if event in _fork_events: 

1793 if _state.transition_context.depth or _state.fork_owner_depths: 

1794 msg = f"{event} is unsafe while filelock is changing descriptor ownership" 

1795 raise RuntimeError(msg) 

1796 elif event == "_posixsubprocess.fork_exec" and _state.transition_context.depth: 

1797 msg = "fork_exec is unsafe while filelock is changing descriptor ownership" 

1798 raise RuntimeError(msg) 

1799 

1800 

1801_register_fork_hooks() 

1802 

1803 

1804__all__ = [ 

1805 "_UNSET_FILE_MODE", 

1806 "AcquireReturnProxy", 

1807 "BaseFileLock", 

1808 "CloseErrorPolicy", 

1809 "ContextErrorPolicy", 

1810 "FileLockContext", 

1811 "FileLockMeta", 

1812 "LockOptions", 

1813 "_append_exception_context", 

1814 "_canonical", 

1815 "_ensure_current_process", 

1816 "_fork_transition", 

1817 "_grouped_errors", 

1818 "_raise_body_and_release", 

1819 "_raise_chained_errors", 

1820 "_raise_cleanup_errors", 

1821 "_raise_grouped_errors", 

1822 "_register_fork_class", 

1823 "_register_fork_object", 

1824 "_register_owned_descriptor", 

1825 "_unregister_owned_descriptor", 

1826]