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
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
1from __future__ import annotations
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
21from ._error import SoftFileLockLifetimeWarning, Timeout
22from ._util import break_lock_file
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
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
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"})
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"})
39if TYPE_CHECKING:
40 from collections.abc import Generator
41 from types import TracebackType
42 from typing import Protocol
44 from _typeshed import Unused
46 from ._read_write import ReadWriteLock
47 from ._soft_rw import SoftReadWriteLock
49 class _ForkResettable(Protocol):
50 def _reset_after_fork_in_child(self) -> None: ...
52 class _ForkDescriptorOwner(Protocol):
53 def _descriptors_for_fork(self) -> tuple[tuple[int, tuple[int, int] | None], ...]: ...
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: ...
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: ...
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
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
78_ExtraValue = TypeVar("_ExtraValue")
79_MarkerValue = TypeVar("_MarkerValue")
80_SubclassValue = TypeVar("_SubclassValue")
81_LockInitValue = float | int | bool | str | None | Callable[[int], None]
84class LockOptions(TypedDict, total=False):
85 """Every option the metaclass forwards, so a subclass adding its own can still type what it passes through."""
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
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 )
111 return _Backport # pragma: no cover (<py311)
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
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)
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
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
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
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
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
236if TYPE_CHECKING:
238 class _ExceptionGroupProtocol(Protocol):
239 @property
240 def message(self) -> str: ...
242 @property
243 def exceptions(self) -> tuple[BaseException, ...]: ...
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
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)
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)
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 )
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
313def _canonical(path: str | os.PathLike[str]) -> str:
314 """
315 Return one stable key for *path*, collapsing equivalent spellings without following a final symlink.
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
328class _ThreadLocalRegistry(local):
329 def __init__(self) -> None:
330 super().__init__()
331 self.held: dict[Hashable, int] = {}
334_registry: Final[_ThreadLocalRegistry] = _ThreadLocalRegistry()
337_T = TypeVar("_T", bound="BaseFileLock")
340class FileLockMeta(ABCMeta):
341 _instances: WeakValueDictionary[str, BaseFileLock]
342 _instances_lock: RLock
343 _instances_under_construction: set[str]
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)
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
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
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)
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)
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 )
471_INIT_PARAMETER_MODELS: Final[WeakKeyDictionary[type[BaseFileLock], _InitParameterModel]] = WeakKeyDictionary()
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
496@dataclass(frozen=True)
497class _InitParameterModel:
498 accepted_params: frozenset[str]
499 accepts_kwargs: bool
500 default_params: dict[str, inspect.Parameter]
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.
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
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)
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)
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
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
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.
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>`).
595 """
597 _instances: WeakValueDictionary[str, BaseFileLock]
598 _instances_lock: RLock
599 _instances_under_construction: set[str]
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"
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
608 #: Strict-lock and lease replacements for a backend with legacy age-based expiry.
609 _lifetime_replacements: tuple[str, str] | None = None
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"
614 #: Async construction adds one metaclass frame before lifetime validation.
615 _constructor_lifetime_warning_stacklevel: int = 3
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
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
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
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
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)
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()
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.
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.
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
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)
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
741 @property
742 def is_singleton(self) -> bool:
743 """
744 A flag indicating if this lock is singleton or not.
746 .. versionadded:: 3.13.0
748 """
749 return self._is_singleton
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.
756 .. versionadded:: 3.30.0
758 """
759 return self._context_error_policy
761 @property
762 def close_error_policy(self) -> CloseErrorPolicy:
763 """
764 What a lock does with an ``os.close`` failure after relinquishing descriptor ownership.
766 .. versionadded:: 3.30.0
768 """
769 return self._close_error_policy
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
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``.
786 Only :class:`UnixFileLock` acts on it: when ``False`` an ``ENOSYS`` from ``flock`` propagates instead of
787 switching to existence-lock semantics.
789 .. versionadded:: 3.30.0
791 """
792 return self._fallback_to_soft
794 @property
795 def preserve_lock_file(self) -> bool:
796 """
797 Whether filelock promises not to unlink the lock pathname on release.
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.
802 .. versionadded:: 3.30.0
804 """
805 return self._preserve_lock_file
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``.
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.
815 .. versionadded:: 3.30.0
817 """
818 return self._on_acquired
820 @property
821 def lock_file(self) -> str:
822 """Path to the lock file."""
823 return self._context.lock_file
825 @property
826 def timeout(self) -> float:
827 """
828 The default timeout value, in seconds.
830 .. versionadded:: 2.0.0
832 """
833 return self._context.timeout
835 @timeout.setter
836 def timeout(self, value: float | str) -> None:
837 """
838 Change the default timeout value.
840 :param value: the new value, in seconds
842 """
843 self._context.timeout = float(value)
845 @property
846 def blocking(self) -> bool:
847 """
848 Whether the locking is blocking or not.
850 .. versionadded:: 3.14.0
852 """
853 return self._context.blocking
855 @blocking.setter
856 def blocking(self, value: bool) -> None:
857 """
858 Change the default blocking value.
860 :param value: the new value as bool
862 """
863 self._context.blocking = value
865 @property
866 def poll_interval(self) -> float:
867 """
868 The default polling interval, in seconds.
870 .. versionadded:: 3.24.0
872 """
873 return self._context.poll_interval
875 @poll_interval.setter
876 def poll_interval(self, value: float) -> None:
877 """
878 Change the default polling interval.
880 :param value: the new value, in seconds
882 """
883 self._context.poll_interval = value
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.
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.
893 .. versionadded:: 3.24.0
895 """
896 return self._context.lifetime
898 @lifetime.setter
899 def lifetime(self, value: float | None) -> None:
900 """
901 Change the legacy age-based expiry threshold.
903 :param value: the new value in seconds, or ``None`` to disable expiration
905 :raises ValueError: if *value* is negative or not finite
906 :raises TypeError: if *value* is not ``None`` and not a real number
908 """
909 self._context.lifetime = _resolve_lifetime(value, type(self), stacklevel=3)
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
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
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
925 @property
926 def is_locked(self) -> bool:
927 """
928 A boolean indicating if the lock file is holding the lock currently.
930 .. versionchanged:: 2.0.0
932 This was previously a method and is now a property.
934 """
935 _ensure_current_process()
936 return self._context.lock_file_fd is not None
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
944 def __enter__(self) -> Self:
945 """
946 Acquire the lock.
948 :returns: the lock object
950 """
951 self.acquire()
952 return self
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)
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)
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)
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.
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.
1005 :returns: a context object that will unlock the file when the context is exited
1007 :raises Timeout: if fails to acquire lock within the timeout period
1009 .. code-block:: python
1011 # You can use this method in the context manager (recommended)
1012 with lock.acquire():
1013 pass
1015 # Or use an equivalent try-finally construct:
1016 lock.acquire()
1017 try:
1018 pass
1019 finally:
1020 lock.release()
1022 .. versionchanged:: 2.0.0
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.
1027 """
1028 self._raise_if_inherited()
1029 if timeout is None:
1030 timeout = self._context.timeout
1032 if blocking is None:
1033 blocking = self._context.blocking
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
1040 poll_interval = poll_interval if poll_interval is not None else self._context.poll_interval
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
1055 canonical = _canonical(self.lock_file)
1056 self._raise_if_would_deadlock(canonical, timeout=timeout, blocking=blocking)
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)
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()
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.
1104 :param force: If true, the lock counter is ignored and the lock is released in every case.
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
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)
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)
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
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
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
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
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)
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.
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)
1189 def _registry_key(self, canonical: str) -> Hashable:
1190 return canonical if (scope := self._deadlock_scope()) is None else (scope, canonical)
1192 @staticmethod
1193 def _deadlock_scope() -> Hashable | None:
1194 """
1195 Execution unit whose own hold would deadlock a new acquire.
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
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)
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
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()
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
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()
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 )
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)
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()
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 )
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)
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
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()
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)
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)
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()
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)
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
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
1426 @abstractmethod
1427 def _release(self) -> None:
1428 """Releases the lock and sets self._context.lock_file_fd to None."""
1429 raise NotImplementedError
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."""
1437 def __init__(self, lock: BaseFileLock | ReadWriteLock | SoftReadWriteLock) -> None:
1438 self.lock: BaseFileLock | ReadWriteLock | SoftReadWriteLock = lock
1440 def __enter__(self) -> BaseFileLock | ReadWriteLock | SoftReadWriteLock:
1441 return self.lock
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()
1455@dataclass
1456class FileLockContext:
1457 """Holds the context for a ``BaseFileLock`` object."""
1459 # A separate class so ThreadLocalFileContext can make the whole context thread-local.
1461 lock_file: str
1462 timeout: float
1463 mode: int
1464 blocking: bool
1465 poll_interval: float
1467 #: The lock lifetime in seconds; ``None`` means the lock never expires.
1468 lifetime: float | None = None
1470 #: File descriptor from os.open for the lock file; not None while the lock is held.
1471 lock_file_fd: int | None = None
1473 #: Registry token for the descriptor owned by this thread's context.
1474 lock_file_fd_token: int | None = None
1476 #: Identity captured by a backend that already inspected the descriptor.
1477 lock_file_fd_identity: tuple[int, int] | None = None
1479 #: Descriptor opened by a backend but not yet committed as the held lock.
1480 pending_lock_file_fd: int | None = None
1482 #: Identity captured for a descriptor whose acquisition has not committed.
1483 pending_lock_file_fd_identity: tuple[int, int] | None = None
1485 #: Depth of nested acquisitions; the lock is released only when it returns to 0.
1486 lock_counter: int = 0
1488 #: Canonical registry key captured when the first physical acquisition commits.
1489 lock_file_key: Hashable | None = None
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, ...] = ()
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
1499class ThreadLocalFileContext(FileLockContext, local):
1500 """A thread local version of the ``FileLockContext`` class."""
1503@dataclass(frozen=True)
1504class _OwnedDescriptor:
1505 fd: int
1506 creator_pid: int
1507 device: int | None
1508 inode: int | None
1511class _ForkTransitionContext(local):
1512 depth: int = 0
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()
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 = {}
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"})
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 )
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)
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()
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()
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)
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)
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
1624def _unregister_owned_descriptor(token: int) -> None: # pragma: needs fork
1625 with _fork_transition(), _FORK_STATE.registry_lock:
1626 _OWNED_DESCRIPTORS.pop(token, None)
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()))
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()
1672def _ensure_current_process() -> None:
1673 _reset_child_after_fork()
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)
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
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()
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 )
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)
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()
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)
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)
1801_register_fork_hooks()
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]