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

919 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 ._read_write import ReadWriteLock 

45 from ._soft_rw import SoftReadWriteLock 

46 

47 class _ForkResettable(Protocol): 

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

49 

50 class _ForkDescriptorOwner(Protocol): 

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

52 

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

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

55 class _ForkResettableClass(Protocol): 

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

57 

58 class _RegisterAtFork(Protocol): 

59 def __call__( 

60 self, 

61 *, 

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

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

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

65 ) -> None: ... 

66 

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

68 from typing import Self 

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

70 from typing_extensions import Self 

71 

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

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

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

75 

76_ExtraValue = TypeVar("_ExtraValue") 

77_MarkerValue = TypeVar("_MarkerValue") 

78_SubclassValue = TypeVar("_SubclassValue") 

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

80 

81 

82class LockOptions(TypedDict, total=False): 

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

84 

85 timeout: float 

86 mode: int 

87 thread_local: bool 

88 blocking: bool 

89 is_singleton: bool 

90 poll_interval: float 

91 lifetime: float | None 

92 context_error_policy: ContextErrorPolicy 

93 close_error_policy: CloseErrorPolicy 

94 fallback_to_soft: bool 

95 preserve_lock_file: bool 

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

97 

98 

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

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

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

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

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

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

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

106 BaseExceptionGroup as _Backport, 

107 ) 

108 

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

110 

111 

112def _raise_grouped_errors( 

113 message: str, 

114 first_error: BaseException, 

115 second_error: BaseException, 

116 *additional_errors: BaseException, 

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

118) -> NoReturn: 

119 errors = (first_error, second_error, *additional_errors) 

120 _detach_grouped_contexts(errors) 

121 group = _exception_group_cls()(message, errors) 

122 if marker is not None: 

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

124 raise group from None 

125 

126 

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

128 seen: set[int] = set() 

129 pending = list(errors) 

130 while pending: 

131 error = pending.pop() 

132 if id(error) in seen: 

133 continue 

134 seen.add(id(error)) 

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

136 context is error 

137 or _same_exception_tree(error, context) 

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

139 ): 

140 error.__context__ = None 

141 elif context is not None: 

142 pending.append(context) 

143 if error.__cause__ is not None: 

144 pending.append(error.__cause__) 

145 if isinstance(error, _exception_group_cls()): 

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

147 

148 

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

150 pending = [(first, second)] 

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

152 while pending: 

153 first_error, second_error = pending.pop() 

154 if first_error is second_error: 

155 continue 

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

157 continue 

158 seen.add(pair) 

159 if ( 

160 type(first_error) is not type(second_error) 

161 or not isinstance(first_error, _exception_group_cls()) 

162 or not isinstance(second_error, _exception_group_cls()) 

163 ): 

164 return False 

165 first_group = cast("_ExceptionGroupProtocol", first_error) 

166 second_group = cast("_ExceptionGroupProtocol", second_error) 

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

168 return False 

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

170 return True 

171 

172 

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

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

175 return False 

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

177 seen: set[int] = set() 

178 while pending: 

179 child = pending.pop() 

180 if child is target: 

181 return True 

182 if id(child) in seen: 

183 continue 

184 seen.add(id(child)) 

185 if isinstance(child, _exception_group_cls()): 

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

187 return False 

188 

189 

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

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

192 return 

193 if error.__context__ is None: 

194 error.__context__ = context 

195 return 

196 tail = error 

197 seen: set[int] = set() 

198 while id(tail) not in seen: 

199 seen.add(id(tail)) 

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

201 tail.__context__ = context 

202 return 

203 tail = next_error 

204 

205 

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

207 pending = [error] 

208 seen: set[int] = set() 

209 while pending: 

210 current = pending.pop() 

211 if current is target: 

212 return True 

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

214 continue 

215 seen.add(id(current)) 

216 if current.__cause__ is not None: 

217 pending.append(current.__cause__) 

218 if current.__context__ is not None: 

219 pending.append(current.__context__) 

220 if isinstance(current, _exception_group_cls()): 

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

222 return False 

223 

224 

225def _grouped_errors( 

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

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

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

229 return None 

230 group = cast("_ExceptionGroupProtocol", error) 

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

232 

233 

234if TYPE_CHECKING: 

235 

236 class _ExceptionGroupProtocol(Protocol): 

237 @property 

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

239 

240 @property 

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

242 

243 

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

245 if second_error is None: 

246 first_context = first_error.__context__ 

247 try: 

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

249 except BaseException: 

250 first_error.__context__ = first_context 

251 raise 

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

253 _detach_exception_context(second_context, first_error) 

254 _append_exception_context(first_error, second_context) 

255 first_context = first_error.__context__ 

256 try: 

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

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

259 first_error.__context__ = first_context 

260 try: 

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

262 except BaseException: 

263 second_error.__context__ = first_error 

264 first_error.__context__ = first_context 

265 raise 

266 

267 

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

269 pending = [error] 

270 seen: set[int] = set() 

271 while pending: 

272 current = pending.pop() 

273 if id(current) in seen: 

274 continue 

275 seen.add(id(current)) 

276 if current.__context__ is target: 

277 current.__context__ = None 

278 elif current.__context__ is not None: 

279 pending.append(current.__context__) 

280 if current.__cause__ is not None: 

281 pending.append(current.__cause__) 

282 if isinstance(current, _exception_group_cls()): 

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

284 

285 

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

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

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

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

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

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

292 

293 

294def _raise_cleanup_errors( 

295 message: str, 

296 primary_error: BaseException, 

297 *cleanup_errors: BaseException | None, 

298) -> NoReturn: 

299 _raise_grouped_errors( 

300 message, 

301 primary_error, 

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

303 ) 

304 

305 

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

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

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

309 

310 

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

312 """ 

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

314 

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

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

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

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

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

320 not followed either. 

321 """ 

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

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

324 

325 

326class _ThreadLocalRegistry(local): 

327 def __init__(self) -> None: 

328 super().__init__() 

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

330 

331 

332_registry: Final[_ThreadLocalRegistry] = _ThreadLocalRegistry() 

333 

334 

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

336 

337 

338class FileLockMeta(ABCMeta): 

339 _instances: WeakValueDictionary[str, BaseFileLock] 

340 _instances_lock: RLock 

341 _instances_under_construction: set[str] 

342 

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

344 cls: type[_T], 

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

346 timeout: float = -1, 

347 mode: int = _UNSET_FILE_MODE, 

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

349 *, 

350 blocking: bool = True, 

351 is_singleton: bool = False, 

352 poll_interval: float = 0.05, 

353 lifetime: float | None = None, 

354 context_error_policy: ContextErrorPolicy = "chain", 

355 close_error_policy: CloseErrorPolicy = "default", 

356 fallback_to_soft: bool = True, 

357 preserve_lock_file: bool = False, 

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

359 **kwargs: _ExtraValue, 

360 ) -> _T: 

361 _ensure_current_process() 

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

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

364 # __del__ then trips over the missing context. 

365 context_error_policy = _resolve_context_error_policy(context_error_policy) 

366 close_error_policy = _resolve_close_error_policy(close_error_policy) 

367 preserve_lock_file = _resolve_preserve_lock_file( 

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

369 ) 

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

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

372 "timeout": timeout, 

373 "mode": mode, 

374 "thread_local": thread_local, 

375 "blocking": blocking, 

376 "is_singleton": is_singleton, 

377 "poll_interval": poll_interval, 

378 "lifetime": lifetime, 

379 "context_error_policy": context_error_policy, 

380 "close_error_policy": close_error_policy, 

381 "fallback_to_soft": fallback_to_soft, 

382 "preserve_lock_file": preserve_lock_file, 

383 "on_acquired": on_acquired, 

384 **kwargs, 

385 } 

386 if not is_singleton: 

387 return cls._create_instance(lock_file, params) 

388 

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

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

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

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

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

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

395 # deadlock-registry key acquire() uses. 

396 singleton_key = _canonical(lock_file) 

397 with cls._instances_lock: 

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

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

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

401 raise RuntimeError(msg) 

402 construction_registry = cls._instances_under_construction 

403 construction_pid = os.getpid() 

404 construction_registry.add(singleton_key) 

405 try: 

406 instance = cls._create_instance(lock_file, params) 

407 finally: 

408 construction_registry.discard(singleton_key) 

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

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

411 raise RuntimeError(msg) 

412 cls._instances[singleton_key] = instance 

413 return instance 

414 

415 params_to_check = { 

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

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

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

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

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

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

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

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

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

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

426 } 

427 non_matching_params = { 

428 name: (passed_param, set_param) 

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

430 if passed_param != set_param 

431 } 

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

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

434 hook_mismatch = on_acquired is not instance.on_acquired 

435 if not non_matching_params and not hook_mismatch: 

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

437 

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

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

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

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

442 if hook_mismatch: 

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

444 raise ValueError(msg) 

445 

446 def _create_instance( 

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

448 ) -> _T: 

449 model = _init_parameter_model(cls) 

450 if model.accepts_kwargs: 

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

452 

453 unsupported = sorted( 

454 name 

455 for name, value in params.items() 

456 if name not in model.accepted_params 

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

458 ) 

459 if unsupported: 

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

461 raise TypeError(msg) 

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

463 return super().__call__( 

464 lock_file, 

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

466 ) 

467 

468 

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

470 

471 

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

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

474 with _fork_transition(), _FORK_STATE.parameter_models_lock: 

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

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

477 model = _InitParameterModel( 

478 accepted_params=frozenset( 

479 parameter.name 

480 for parameter in parameters 

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

482 ), 

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

484 default_params={ 

485 name: parameter 

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

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

488 }, 

489 ) 

490 _INIT_PARAMETER_MODELS[cls] = model 

491 return model 

492 

493 

494@dataclass(frozen=True) 

495class _InitParameterModel: 

496 accepted_params: frozenset[str] 

497 accepts_kwargs: bool 

498 default_params: dict[str, inspect.Parameter] 

499 

500 

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

502 """ 

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

504 

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

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

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

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

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

510 """ 

511 if lifetime is not None: 

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

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

514 raise TypeError(msg) 

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

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

517 raise ValueError(msg) 

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

519 warnings.warn( 

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

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

522 stacklevel=stacklevel, 

523 ) 

524 return None 

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

526 strict_lock, lease = cls._lifetime_replacements 

527 warnings.warn( 

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

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

530 SoftFileLockLifetimeWarning, 

531 stacklevel=stacklevel, 

532 ) 

533 return lifetime 

534 

535 

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

537 if policy not in _CONTEXT_ERROR_POLICIES: 

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

539 raise ValueError(msg) 

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

541 try: 

542 _exception_group_cls() 

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

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

545 raise ValueError(msg) from exc 

546 return cast("ContextErrorPolicy", policy) 

547 

548 

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

550 if policy not in _CLOSE_ERROR_POLICIES: 

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

552 raise ValueError(msg) 

553 return cast("CloseErrorPolicy", policy) 

554 

555 

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

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

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

559 if preserve and not supported: 

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

561 raise ValueError(msg) 

562 return preserve 

563 

564 

565def _resolve_on_acquired( 

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

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

568 if on_acquired is None: 

569 return None 

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

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

572 if not supported: 

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

574 raise ValueError(msg) 

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

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

577 try: 

578 _exception_group_cls() 

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

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

581 raise ValueError(msg) from exc 

582 return on_acquired 

583 

584 

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

586 """ 

587 Abstract base class for a file lock object. 

588 

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

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

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

592 

593 """ 

594 

595 _instances: WeakValueDictionary[str, BaseFileLock] 

596 _instances_lock: RLock 

597 _instances_under_construction: set[str] 

598 

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

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

601 

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

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

604 _lifetime_supported: bool = False 

605 

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

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

608 

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

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

611 

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

613 _constructor_lifetime_warning_stacklevel: int = 3 

614 

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

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

617 _preserve_lock_file_supported: bool = True 

618 

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

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

621 _on_acquired_supported: bool = True 

622 

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

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

625 _serialize_transitions: bool = False 

626 

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

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

629 _poll_backoff_cap: float = 0.0 

630 

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

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

633 super().__init_subclass__(**kwargs) 

634 cls._instances = WeakValueDictionary() 

635 cls._instances_lock = RLock() 

636 cls._instances_under_construction = set() 

637 _register_fork_class(cls) 

638 

639 @classmethod 

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

641 cls._instances = WeakValueDictionary() 

642 cls._instances_lock = RLock() 

643 cls._instances_under_construction = set() 

644 

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

646 self, 

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

648 timeout: float = -1, 

649 mode: int = _UNSET_FILE_MODE, 

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

651 *, 

652 blocking: bool = True, 

653 is_singleton: bool = False, 

654 poll_interval: float = 0.05, 

655 lifetime: float | None = None, 

656 context_error_policy: ContextErrorPolicy = "chain", 

657 close_error_policy: CloseErrorPolicy = "default", 

658 fallback_to_soft: bool = True, 

659 preserve_lock_file: bool = False, 

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

661 ) -> None: 

662 """ 

663 Create a new lock object. 

664 

665 :param lock_file: path to the file 

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

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

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

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

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

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

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

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

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

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

676 at construction time. If you need configuration values to be visible across threads, construct the lock 

677 with ``thread_local=False``. 

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

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

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

681 same object around. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

696 lock-file deletion. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

713 

714 """ 

715 self._creator_pid = os.getpid() 

716 self._transition_lock = RLock() 

717 self._is_thread_local = thread_local 

718 self._is_singleton = is_singleton 

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

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

721 self._fallback_to_soft = fallback_to_soft 

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

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

724 

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

726 lock_file=os.fspath(lock_file), 

727 timeout=timeout, 

728 mode=mode, 

729 blocking=blocking, 

730 poll_interval=poll_interval, 

731 lifetime=lifetime, 

732 ) 

733 _register_fork_object(self) 

734 

735 def is_thread_local(self) -> bool: 

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

737 return self._is_thread_local 

738 

739 @property 

740 def is_singleton(self) -> bool: 

741 """ 

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

743 

744 .. versionadded:: 3.13.0 

745 

746 """ 

747 return self._is_singleton 

748 

749 @property 

750 def context_error_policy(self) -> ContextErrorPolicy: 

751 """ 

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

753 

754 .. versionadded:: 3.30.0 

755 

756 """ 

757 return self._context_error_policy 

758 

759 @property 

760 def close_error_policy(self) -> CloseErrorPolicy: 

761 """ 

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

763 

764 .. versionadded:: 3.30.0 

765 

766 """ 

767 return self._close_error_policy 

768 

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

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

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

772 try: 

773 os.close(fd) 

774 except OSError: 

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

776 return 

777 raise 

778 

779 @property 

780 def fallback_to_soft(self) -> bool: 

781 """ 

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

783 

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

785 switching to existence-lock semantics. 

786 

787 .. versionadded:: 3.30.0 

788 

789 """ 

790 return self._fallback_to_soft 

791 

792 @property 

793 def preserve_lock_file(self) -> bool: 

794 """ 

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

796 

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

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

799 

800 .. versionadded:: 3.30.0 

801 

802 """ 

803 return self._preserve_lock_file 

804 

805 @property 

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

807 """ 

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

809 

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

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

812 

813 .. versionadded:: 3.30.0 

814 

815 """ 

816 return self._on_acquired 

817 

818 @property 

819 def lock_file(self) -> str: 

820 """Path to the lock file.""" 

821 return self._context.lock_file 

822 

823 @property 

824 def timeout(self) -> float: 

825 """ 

826 The default timeout value, in seconds. 

827 

828 .. versionadded:: 2.0.0 

829 

830 """ 

831 return self._context.timeout 

832 

833 @timeout.setter 

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

835 """ 

836 Change the default timeout value. 

837 

838 :param value: the new value, in seconds 

839 

840 """ 

841 self._context.timeout = float(value) 

842 

843 @property 

844 def blocking(self) -> bool: 

845 """ 

846 Whether the locking is blocking or not. 

847 

848 .. versionadded:: 3.14.0 

849 

850 """ 

851 return self._context.blocking 

852 

853 @blocking.setter 

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

855 """ 

856 Change the default blocking value. 

857 

858 :param value: the new value as bool 

859 

860 """ 

861 self._context.blocking = value 

862 

863 @property 

864 def poll_interval(self) -> float: 

865 """ 

866 The default polling interval, in seconds. 

867 

868 .. versionadded:: 3.24.0 

869 

870 """ 

871 return self._context.poll_interval 

872 

873 @poll_interval.setter 

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

875 """ 

876 Change the default polling interval. 

877 

878 :param value: the new value, in seconds 

879 

880 """ 

881 self._context.poll_interval = value 

882 

883 @property 

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

885 """ 

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

887 

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

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

890 

891 .. versionadded:: 3.24.0 

892 

893 """ 

894 return self._context.lifetime 

895 

896 @lifetime.setter 

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

898 """ 

899 Change the legacy age-based expiry threshold. 

900 

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

902 

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

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

905 

906 """ 

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

908 

909 @property 

910 def mode(self) -> int: 

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

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

913 

914 @property 

915 def has_explicit_mode(self) -> bool: 

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

917 return self._context.mode != _UNSET_FILE_MODE 

918 

919 def _open_mode(self) -> int: 

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

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

922 

923 @property 

924 def is_locked(self) -> bool: 

925 """ 

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

927 

928 .. versionchanged:: 2.0.0 

929 

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

931 

932 """ 

933 _ensure_current_process() 

934 return self._context.lock_file_fd is not None 

935 

936 @property 

937 def lock_counter(self) -> int: 

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

939 _ensure_current_process() 

940 return self._context.lock_counter 

941 

942 def __enter__(self) -> Self: 

943 """ 

944 Acquire the lock. 

945 

946 :returns: the lock object 

947 

948 """ 

949 self.acquire() 

950 return self 

951 

952 def __exit__( 

953 self, 

954 exc_type: type[BaseException] | None, 

955 exc_value: BaseException | None, 

956 traceback: TracebackType | None, 

957 ) -> None: 

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

959 self._release_in_context(exc_value) 

960 

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

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

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

964 try: 

965 self.release() 

966 except BaseException as release_error: 

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

968 raise 

969 _raise_body_and_release(body_error, release_error) 

970 

971 def __del__(self) -> None: 

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

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

974 return # pragma: forked child 

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

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

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

978 with contextlib.suppress(Exception): 

979 self.release(force=True) 

980 

981 def acquire( 

982 self, 

983 timeout: float | None = None, 

984 poll_interval: float | None = None, 

985 *, 

986 poll_intervall: float | None = None, 

987 blocking: bool | None = None, 

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

989 ) -> AcquireReturnProxy: 

990 """ 

991 Try to acquire the file lock. 

992 

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

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

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

996 :attr:`~poll_interval` 

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

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

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

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

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

1002 

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

1004 

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

1006 

1007 .. code-block:: python 

1008 

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

1010 with lock.acquire(): 

1011 pass 

1012 

1013 # Or use an equivalent try-finally construct: 

1014 lock.acquire() 

1015 try: 

1016 pass 

1017 finally: 

1018 lock.release() 

1019 

1020 .. versionchanged:: 2.0.0 

1021 

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

1023 without side effects. 

1024 

1025 """ 

1026 self._raise_if_inherited() 

1027 if timeout is None: 

1028 timeout = self._context.timeout 

1029 

1030 if blocking is None: 

1031 blocking = self._context.blocking 

1032 

1033 if poll_intervall is not None: 

1034 msg = "use poll_interval instead of poll_intervall" 

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

1036 poll_interval = poll_intervall 

1037 

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

1039 

1040 start_time = time.perf_counter() 

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

1042 # the descriptor exactly as it found them. 

1043 with self._transition_admission( 

1044 blocking=blocking, 

1045 cancel_check=cancel_check, 

1046 timeout=timeout, 

1047 poll_interval=poll_interval, 

1048 start_time=start_time, 

1049 ): 

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

1051 self._context.lock_counter += 1 

1052 

1053 canonical = _canonical(self.lock_file) 

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

1055 

1056 try: 

1057 self._poll_until_acquired( 

1058 blocking=blocking, 

1059 cancel_check=cancel_check, 

1060 timeout=timeout, 

1061 poll_interval=poll_interval, 

1062 start_time=start_time, 

1063 ) 

1064 except BaseException: 

1065 self._reconcile_failed_acquire(canonical) 

1066 raise 

1067 self._commit_acquire(canonical) 

1068 return AcquireReturnProxy(lock=self) 

1069 

1070 @contextlib.contextmanager 

1071 def _transition_admission( 

1072 self, 

1073 *, 

1074 blocking: bool, 

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

1076 timeout: float, 

1077 poll_interval: float, 

1078 start_time: float, 

1079 ) -> Generator[None]: 

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

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

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

1083 if not self._serialize_transitions or self._is_thread_local: 

1084 yield 

1085 return 

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

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

1088 raise Timeout(self.lock_file) 

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

1090 raise Timeout(self.lock_file) 

1091 time.sleep(poll_interval) 

1092 try: # pragma: needs hard-link 

1093 yield 

1094 finally: # pragma: needs hard-link 

1095 self._transition_lock.release() 

1096 

1097 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 

1098 """ 

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

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

1101 

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

1103 

1104 """ 

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

1106 # never observes a partially torn-down owner. 

1107 serialize = self._serialize_transitions and not self._is_thread_local 

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

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

1110 return 

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

1112 self._context.lock_counter -= 1 

1113 return 

1114 

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

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

1117 try: 

1118 self._release_with_fork_tracking() 

1119 except BaseException: 

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

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

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

1123 if not self.is_locked: 

1124 self._commit_release() 

1125 raise 

1126 self._commit_release() 

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

1128 

1129 def _raise_if_inherited(self) -> None: 

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

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

1132 raise RuntimeError(msg) 

1133 

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

1135 self._context.pending_lock_file_fd = None 

1136 self._context.pending_lock_file_fd_identity = None 

1137 self._context.lock_file_fd = fd 

1138 self._context.lock_file_fd_identity = identity 

1139 

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

1141 self._context.pending_lock_file_fd = fd 

1142 self._context.pending_lock_file_fd_identity = identity 

1143 

1144 def _mark_descriptor_released(self) -> None: 

1145 self._context.pending_lock_file_fd = None 

1146 self._context.pending_lock_file_fd_identity = None 

1147 self._context.lock_file_fd = None 

1148 self._context.lock_file_fd_identity = None 

1149 

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

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

1152 self._transition_lock = RLock() 

1153 self._context.owner_claim_paths = () 

1154 self._context.claim_root = None 

1155 self._context.lock_file_fd = None 

1156 self._context.lock_file_fd_token = None 

1157 self._context.lock_file_fd_identity = None 

1158 self._context.pending_lock_file_fd = None 

1159 self._context.pending_lock_file_fd_identity = None 

1160 self._context.lock_counter = 0 

1161 self._context.lock_file_key = None 

1162 

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

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

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

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

1167 if self._context.pending_lock_file_fd is not None: 

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

1169 return tuple(descriptors) 

1170 

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

1172 """ 

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

1174 

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

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

1177 """ 

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

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

1180 self._context.lock_counter -= 1 

1181 msg = ( 

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

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

1184 ) 

1185 raise RuntimeError(msg) 

1186 

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

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

1189 

1190 @staticmethod 

1191 def _deadlock_scope() -> Hashable | None: 

1192 """ 

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

1194 

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

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

1197 the *same* task can self-deadlock. 

1198 """ 

1199 return None 

1200 

1201 def _poll_until_acquired( 

1202 self, 

1203 *, 

1204 blocking: bool, 

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

1206 timeout: float, 

1207 poll_interval: float, 

1208 start_time: float, 

1209 ) -> None: 

1210 lock_id = id(self) 

1211 lock_filename = self.lock_file 

1212 attempt = 0 

1213 while True: 

1214 self._raise_if_inherited() 

1215 if not self.is_locked: 

1216 self._try_break_expired_lock() 

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

1218 self._acquire_with_fork_tracking() 

1219 self._raise_if_inherited() 

1220 if self.is_locked: 

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

1222 return 

1223 if self._check_give_up( 

1224 blocking=blocking, 

1225 cancel_check=cancel_check, 

1226 timeout=timeout, 

1227 start_time=start_time, 

1228 ): 

1229 raise Timeout(lock_filename) 

1230 attempt += 1 

1231 delay = self._poll_delay(poll_interval, attempt) 

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

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

1234 time.sleep(delay) 

1235 

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

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

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

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

1240 if not self._poll_backoff_cap: 

1241 return poll_interval 

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

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

1244 window = min( 

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

1246 ) # pragma: needs hard-link 

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

1248 

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

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

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

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

1253 if self.is_locked: 

1254 self._commit_acquire(canonical) 

1255 else: 

1256 self._undo_acquire() 

1257 

1258 def _invoke_on_acquired(self) -> None: 

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

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

1261 return 

1262 try: 

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

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

1265 callback_context = callback_error.__context__ 

1266 try: 

1267 self._release_with_fork_tracking() 

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

1269 _raise_body_and_release(callback_error, release_error) 

1270 callback_error.__context__ = callback_context 

1271 raise 

1272 

1273 def _acquire_with_fork_tracking(self) -> None: 

1274 with _fork_transition(self): 

1275 try: 

1276 self._acquire() 

1277 except BaseException as acquisition_error: 

1278 self._rollback_failed_acquire(acquisition_error) 

1279 raise 

1280 try: 

1281 self._register_context_descriptor() 

1282 except BaseException as registration_error: # pragma: needs fork 

1283 self._rollback_failed_registration(registration_error) 

1284 raise 

1285 if self.is_locked: 

1286 self._invoke_on_acquired() 

1287 

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

1289 if not self.is_locked: 

1290 return 

1291 registration_error: BaseException | None = None 

1292 tracking_error: BaseException | None = None 

1293 try: 

1294 self._register_context_descriptor() 

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

1296 registration_error = error 

1297 try: 

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

1299 self._register_unverified_context_descriptor() 

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

1301 tracking_error = error 

1302 try: 

1303 self._release_with_fork_tracking() 

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

1305 _raise_cleanup_errors( 

1306 "lock acquisition cleanup failed", 

1307 acquisition_error, 

1308 registration_error, 

1309 tracking_error, 

1310 rollback_error, 

1311 ) 

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

1313 _raise_cleanup_errors( 

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

1315 ) 

1316 

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

1318 tracking_error: BaseException | None = None 

1319 try: 

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

1321 self._register_unverified_context_descriptor() 

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

1323 tracking_error = error 

1324 try: 

1325 self._release_with_fork_tracking() 

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

1327 _raise_cleanup_errors( 

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

1329 ) 

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

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

1332 

1333 def _release_with_fork_tracking(self) -> None: 

1334 with _fork_transition(self): 

1335 try: 

1336 self._release() 

1337 finally: 

1338 self._unregister_released_descriptor() 

1339 

1340 def _register_context_descriptor(self) -> None: 

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

1342 self._context.lock_file_fd_token = _register_owned_descriptor( 

1343 self._context.lock_file_fd, 

1344 self._context.lock_file_fd_identity, 

1345 ) 

1346 

1347 def _register_unverified_context_descriptor(self) -> None: 

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

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

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

1351 

1352 def _unregister_released_descriptor(self) -> None: 

1353 if self._context.lock_file_fd is None: 

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

1355 _unregister_owned_descriptor(token) 

1356 self._context.lock_file_fd_token = None 

1357 self._context.lock_file_fd_identity = None 

1358 

1359 def _undo_acquire(self) -> None: 

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

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

1362 if self._context.lock_counter == 0: 

1363 self._drop_registry_entry() 

1364 

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

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

1367 if self._context.lock_counter == 1: 

1368 key = self._registry_key(canonical) 

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

1370 self._context.lock_file_key = key 

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

1372 

1373 def _drop_registry_entry(self) -> None: 

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

1375 key = self._context.lock_file_key 

1376 self._context.lock_file_key = None 

1377 if key is not None: 

1378 _registry.held.pop(key, None) 

1379 

1380 def _commit_release(self) -> None: 

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

1382 self._context.lock_counter = 0 

1383 self._drop_registry_entry() 

1384 

1385 def _try_break_expired_lock(self) -> None: 

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

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

1388 return 

1389 with contextlib.suppress(OSError): 

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

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

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

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

1394 st = os.lstat(self.lock_file) 

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

1396 return 

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

1398 

1399 def _check_give_up( 

1400 self, 

1401 *, 

1402 blocking: bool, 

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

1404 timeout: float, 

1405 start_time: float, 

1406 ) -> bool: 

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

1408 if blocking is False: 

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

1410 return True 

1411 if cancel_check is not None and cancel_check(): 

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

1413 return True 

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

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

1416 return True 

1417 return False 

1418 

1419 @abstractmethod 

1420 def _acquire(self) -> None: 

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

1422 raise NotImplementedError 

1423 

1424 @abstractmethod 

1425 def _release(self) -> None: 

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

1427 raise NotImplementedError 

1428 

1429 

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

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

1432class AcquireReturnProxy: 

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

1434 

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

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

1437 

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

1439 return self.lock 

1440 

1441 def __exit__( 

1442 self, 

1443 exc_type: type[BaseException] | None, 

1444 exc_value: BaseException | None, 

1445 traceback: TracebackType | None, 

1446 ) -> None: 

1447 if isinstance(self.lock, BaseFileLock): 

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

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

1450 self.lock.release() 

1451 

1452 

1453@dataclass 

1454class FileLockContext: 

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

1456 

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

1458 

1459 lock_file: str 

1460 timeout: float 

1461 mode: int 

1462 blocking: bool 

1463 poll_interval: float 

1464 

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

1466 lifetime: float | None = None 

1467 

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

1469 lock_file_fd: int | None = None 

1470 

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

1472 lock_file_fd_token: int | None = None 

1473 

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

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

1476 

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

1478 pending_lock_file_fd: int | None = None 

1479 

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

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

1482 

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

1484 lock_counter: int = 0 

1485 

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

1487 lock_file_key: Hashable | None = None 

1488 

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

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

1491 

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

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

1494 claim_root: str | None = None 

1495 

1496 

1497class ThreadLocalFileContext(FileLockContext, local): 

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

1499 

1500 

1501@dataclass(frozen=True) 

1502class _OwnedDescriptor: 

1503 fd: int 

1504 creator_pid: int 

1505 device: int | None 

1506 inode: int | None 

1507 

1508 

1509class _ForkTransitionContext(local): 

1510 depth: int = 0 

1511 

1512 

1513class _ForkState: 

1514 def __init__(self) -> None: 

1515 self.gate = Condition(RLock()) 

1516 self.registry_lock = RLock() 

1517 self.parameter_models_lock = RLock() 

1518 self.transition_context = _ForkTransitionContext() 

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

1520 self.active_transitions = 0 

1521 self.admission_closed = False 

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

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

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

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

1526 self.pid = os.getpid() 

1527 

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

1529 self.gate = Condition(RLock()) 

1530 self.registry_lock = RLock() 

1531 self.parameter_models_lock = RLock() 

1532 self.transition_context = _ForkTransitionContext() 

1533 self.transitions = {} 

1534 self.active_transitions = 0 

1535 self.admission_closed = False 

1536 self.fork_owner_depths = {} 

1537 self.pinned_objects = {} 

1538 self.pinned_classes = {} 

1539 self.provisional_descriptor_tokens = {} 

1540 

1541 

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

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

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

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

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

1547_FORK_STATE: Final = _ForkState() 

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

1549 

1550 

1551def _register_fork_hooks() -> None: 

1552 if _REGISTER_AT_FORK is None: 

1553 return # pragma: lacks fork 

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

1555 _REGISTER_AT_FORK( # pragma: needs fork 

1556 before=_pin_fork_objects, 

1557 after_in_parent=_resume_parent_after_fork, 

1558 after_in_child=_reset_child_after_fork, 

1559 ) 

1560 

1561 

1562@contextmanager 

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

1564 if not _HAS_REGISTER_AT_FORK: 

1565 yield # pragma: lacks fork 

1566 return # pragma: lacks fork 

1567 _ensure_current_process() # pragma: needs fork 

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

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

1570 try: # pragma: needs fork 

1571 yield 

1572 finally: 

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

1574 _leave_fork_transition(token) 

1575 

1576 

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

1578 if not _HAS_REGISTER_AT_FORK: 

1579 return # pragma: lacks fork 

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

1581 _FORK_OBJECTS[id(instance)] = instance 

1582 _refresh_owner_pins() 

1583 

1584 

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

1586 if not _HAS_REGISTER_AT_FORK: 

1587 return # pragma: lacks fork 

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

1589 _FORK_CLASSES[id(cls)] = cls 

1590 _refresh_owner_pins() 

1591 

1592 

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

1594 if not _HAS_REGISTER_AT_FORK: 

1595 return None # pragma: lacks fork 

1596 with _fork_transition(): # pragma: needs fork 

1597 if identity is None: 

1598 stat_result = os.fstat(fd) 

1599 identity = stat_result.st_dev, stat_result.st_ino 

1600 return _record_owned_descriptor(fd, identity) 

1601 

1602 

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

1604 if not _HAS_REGISTER_AT_FORK: 

1605 return None # pragma: lacks fork 

1606 with _fork_transition(): # pragma: needs fork 

1607 return _record_owned_descriptor(fd, None) 

1608 

1609 

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

1611 with _FORK_STATE.registry_lock: 

1612 token = next(_DESCRIPTOR_TOKENS) 

1613 _OWNED_DESCRIPTORS[token] = _OwnedDescriptor( 

1614 fd=fd, 

1615 creator_pid=os.getpid(), 

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

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

1618 ) 

1619 return token 

1620 

1621 

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

1623 with _fork_transition(), _FORK_STATE.registry_lock: 

1624 _OWNED_DESCRIPTORS.pop(token, None) 

1625 

1626 

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

1628 _ensure_current_process() 

1629 thread_id = get_ident() 

1630 with _FORK_STATE.gate: 

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

1632 _FORK_STATE.admission_closed = True 

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

1634 _FORK_STATE.gate.wait() 

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

1636 _verify_unverified_descriptors() 

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

1638 provisional_descriptor_tokens = tuple( 

1639 starmap( 

1640 _snapshot_descriptor_for_fork, 

1641 (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 

1642 ) 

1643 ) 

1644 with _FORK_STATE.registry_lock: 

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

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

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

1648 

1649 

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

1651 thread_id = get_ident() 

1652 with _FORK_STATE.registry_lock: 

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

1654 _OWNED_DESCRIPTORS.pop(token, None) 

1655 _FORK_STATE.pinned_objects[thread_id].pop() 

1656 _FORK_STATE.pinned_classes[thread_id].pop() 

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

1658 del _FORK_STATE.provisional_descriptor_tokens[thread_id] 

1659 del _FORK_STATE.pinned_objects[thread_id] 

1660 del _FORK_STATE.pinned_classes[thread_id] 

1661 with _FORK_STATE.gate: 

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

1663 del _FORK_STATE.fork_owner_depths[thread_id] 

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

1665 _FORK_STATE.fork_owner_depths[thread_id] -= 1 

1666 _FORK_STATE.admission_closed = bool(_FORK_STATE.fork_owner_depths) 

1667 _FORK_STATE.gate.notify_all() 

1668 

1669 

1670def _ensure_current_process() -> None: 

1671 _reset_child_after_fork() 

1672 

1673 

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

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

1676 return 

1677 thread_id = get_ident() 

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

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

1680 descriptors: list[_OwnedDescriptor] = [] 

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

1682 if descriptor.creator_pid != pid: 

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

1684 _FORK_STATE.reset_synchronization() 

1685 _FORK_STATE.pid = pid 

1686 _INIT_PARAMETER_MODELS.clear() 

1687 _detach_child_state(descriptors, pinned_objects, pinned_classes) 

1688 

1689 

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

1691 thread_id = get_ident() 

1692 with _FORK_STATE.gate: 

1693 while ( 

1694 _FORK_STATE.admission_closed 

1695 and thread_id not in _FORK_STATE.fork_owner_depths 

1696 and thread_id not in _FORK_STATE.transitions 

1697 ): 

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

1699 token = next(_TRANSITION_TOKENS) 

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

1701 _FORK_STATE.active_transitions += 1 

1702 _FORK_STATE.transition_context.depth += 1 

1703 return token 

1704 

1705 

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

1707 _FORK_STATE.transition_context.depth -= 1 

1708 with _FORK_STATE.gate: 

1709 thread_id = get_ident() 

1710 del _FORK_STATE.transitions[thread_id][token] 

1711 if not _FORK_STATE.transitions[thread_id]: 

1712 del _FORK_STATE.transitions[thread_id] 

1713 _FORK_STATE.active_transitions -= 1 

1714 _FORK_STATE.gate.notify_all() 

1715 

1716 

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

1718 with _FORK_STATE.registry_lock: 

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

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

1721 continue 

1722 try: 

1723 stat_result = os.fstat(descriptor.fd) 

1724 except OSError: 

1725 continue 

1726 _OWNED_DESCRIPTORS[token] = _OwnedDescriptor( 

1727 fd=descriptor.fd, 

1728 creator_pid=descriptor.creator_pid, 

1729 device=stat_result.st_dev, 

1730 inode=stat_result.st_ino, 

1731 ) 

1732 

1733 

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

1735 if identity is None: # pragma: needs fork 

1736 try: 

1737 stat_result = os.fstat(fd) 

1738 except OSError: 

1739 pass 

1740 else: 

1741 identity = stat_result.st_dev, stat_result.st_ino 

1742 return _record_owned_descriptor(fd, identity) 

1743 

1744 

1745def _detach_child_state( # pragma: forked child 

1746 descriptors: list[_OwnedDescriptor], 

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

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

1749) -> None: 

1750 for descriptor in descriptors: 

1751 if descriptor.device is None: 

1752 continue 

1753 try: 

1754 stat_result = os.fstat(descriptor.fd) 

1755 except OSError: 

1756 continue 

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

1758 continue 

1759 with contextlib.suppress(OSError): 

1760 os.close(descriptor.fd) 

1761 for instance in pinned_objects: 

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

1763 for cls in pinned_classes: 

1764 cls._reset_class_after_fork() 

1765 _registry.held.clear() 

1766 

1767 

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

1769 with _FORK_STATE.gate: 

1770 fork_owner_thread_ids = tuple(_FORK_STATE.fork_owner_depths) 

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

1772 return 

1773 objects = tuple(_FORK_OBJECTS.values()) 

1774 classes = tuple(_FORK_CLASSES.values()) 

1775 for thread_id in fork_owner_thread_ids: 

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

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

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

1779 

1780 

1781# Audit events carry unrelated interpreter-owned values; object is the sole accurate common element type. 

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

1783 event: str, _args: tuple[object, ...] 

1784) -> None: 

1785 if event in _FORK_AUDIT_EVENTS: 

1786 if _FORK_STATE.transition_context.depth or _FORK_STATE.fork_owner_depths: 

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

1788 raise RuntimeError(msg) 

1789 elif event == "_posixsubprocess.fork_exec" and _FORK_STATE.transition_context.depth: 

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

1791 raise RuntimeError(msg) 

1792 

1793 

1794_register_fork_hooks() 

1795 

1796 

1797__all__ = [ 

1798 "_UNSET_FILE_MODE", 

1799 "AcquireReturnProxy", 

1800 "BaseFileLock", 

1801 "CloseErrorPolicy", 

1802 "ContextErrorPolicy", 

1803 "FileLockContext", 

1804 "FileLockMeta", 

1805 "LockOptions", 

1806 "_append_exception_context", 

1807 "_canonical", 

1808 "_ensure_current_process", 

1809 "_fork_transition", 

1810 "_grouped_errors", 

1811 "_raise_body_and_release", 

1812 "_raise_chained_errors", 

1813 "_raise_cleanup_errors", 

1814 "_raise_grouped_errors", 

1815 "_register_fork_class", 

1816 "_register_fork_object", 

1817 "_register_owned_descriptor", 

1818 "_unregister_owned_descriptor", 

1819]