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 ._read_write import ReadWriteLock
45 from ._soft_rw import SoftReadWriteLock
47 class _ForkResettable(Protocol):
48 def _reset_after_fork_in_child(self) -> None: ...
50 class _ForkDescriptorOwner(Protocol):
51 def _descriptors_for_fork(self) -> tuple[tuple[int, tuple[int, int] | None], ...]: ...
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: ...
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: ...
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
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
76_ExtraValue = TypeVar("_ExtraValue")
77_MarkerValue = TypeVar("_MarkerValue")
78_SubclassValue = TypeVar("_SubclassValue")
79_LockInitValue = float | int | bool | str | None | Callable[[int], None]
82class LockOptions(TypedDict, total=False):
83 """Every option the metaclass forwards, so a subclass adding its own can still type what it passes through."""
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
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 )
109 return _Backport # pragma: no cover (<py311)
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
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)
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
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
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
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
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
234if TYPE_CHECKING:
236 class _ExceptionGroupProtocol(Protocol):
237 @property
238 def message(self) -> str: ...
240 @property
241 def exceptions(self) -> tuple[BaseException, ...]: ...
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
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)
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)
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 )
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
311def _canonical(path: str | os.PathLike[str]) -> str:
312 """
313 Return one stable key for *path*, collapsing equivalent spellings without following a final symlink.
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
326class _ThreadLocalRegistry(local):
327 def __init__(self) -> None:
328 super().__init__()
329 self.held: dict[Hashable, int] = {}
332_registry: Final[_ThreadLocalRegistry] = _ThreadLocalRegistry()
335_T = TypeVar("_T", bound="BaseFileLock")
338class FileLockMeta(ABCMeta):
339 _instances: WeakValueDictionary[str, BaseFileLock]
340 _instances_lock: RLock
341 _instances_under_construction: set[str]
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)
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
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
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)
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)
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 )
469_INIT_PARAMETER_MODELS: Final[WeakKeyDictionary[type[BaseFileLock], _InitParameterModel]] = WeakKeyDictionary()
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
494@dataclass(frozen=True)
495class _InitParameterModel:
496 accepted_params: frozenset[str]
497 accepts_kwargs: bool
498 default_params: dict[str, inspect.Parameter]
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.
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
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)
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)
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
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
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.
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>`).
593 """
595 _instances: WeakValueDictionary[str, BaseFileLock]
596 _instances_lock: RLock
597 _instances_under_construction: set[str]
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"
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
606 #: Strict-lock and lease replacements for a backend with legacy age-based expiry.
607 _lifetime_replacements: tuple[str, str] | None = None
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"
612 #: Async construction adds one metaclass frame before lifetime validation.
613 _constructor_lifetime_warning_stacklevel: int = 3
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
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
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
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
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)
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()
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.
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.
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
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)
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
739 @property
740 def is_singleton(self) -> bool:
741 """
742 A flag indicating if this lock is singleton or not.
744 .. versionadded:: 3.13.0
746 """
747 return self._is_singleton
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.
754 .. versionadded:: 3.30.0
756 """
757 return self._context_error_policy
759 @property
760 def close_error_policy(self) -> CloseErrorPolicy:
761 """
762 What a lock does with an ``os.close`` failure after relinquishing descriptor ownership.
764 .. versionadded:: 3.30.0
766 """
767 return self._close_error_policy
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
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``.
784 Only :class:`UnixFileLock` acts on it: when ``False`` an ``ENOSYS`` from ``flock`` propagates instead of
785 switching to existence-lock semantics.
787 .. versionadded:: 3.30.0
789 """
790 return self._fallback_to_soft
792 @property
793 def preserve_lock_file(self) -> bool:
794 """
795 Whether filelock promises not to unlink the lock pathname on release.
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.
800 .. versionadded:: 3.30.0
802 """
803 return self._preserve_lock_file
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``.
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.
813 .. versionadded:: 3.30.0
815 """
816 return self._on_acquired
818 @property
819 def lock_file(self) -> str:
820 """Path to the lock file."""
821 return self._context.lock_file
823 @property
824 def timeout(self) -> float:
825 """
826 The default timeout value, in seconds.
828 .. versionadded:: 2.0.0
830 """
831 return self._context.timeout
833 @timeout.setter
834 def timeout(self, value: float | str) -> None:
835 """
836 Change the default timeout value.
838 :param value: the new value, in seconds
840 """
841 self._context.timeout = float(value)
843 @property
844 def blocking(self) -> bool:
845 """
846 Whether the locking is blocking or not.
848 .. versionadded:: 3.14.0
850 """
851 return self._context.blocking
853 @blocking.setter
854 def blocking(self, value: bool) -> None:
855 """
856 Change the default blocking value.
858 :param value: the new value as bool
860 """
861 self._context.blocking = value
863 @property
864 def poll_interval(self) -> float:
865 """
866 The default polling interval, in seconds.
868 .. versionadded:: 3.24.0
870 """
871 return self._context.poll_interval
873 @poll_interval.setter
874 def poll_interval(self, value: float) -> None:
875 """
876 Change the default polling interval.
878 :param value: the new value, in seconds
880 """
881 self._context.poll_interval = value
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.
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.
891 .. versionadded:: 3.24.0
893 """
894 return self._context.lifetime
896 @lifetime.setter
897 def lifetime(self, value: float | None) -> None:
898 """
899 Change the legacy age-based expiry threshold.
901 :param value: the new value in seconds, or ``None`` to disable expiration
903 :raises ValueError: if *value* is negative or not finite
904 :raises TypeError: if *value* is not ``None`` and not a real number
906 """
907 self._context.lifetime = _resolve_lifetime(value, type(self), stacklevel=3)
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
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
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
923 @property
924 def is_locked(self) -> bool:
925 """
926 A boolean indicating if the lock file is holding the lock currently.
928 .. versionchanged:: 2.0.0
930 This was previously a method and is now a property.
932 """
933 _ensure_current_process()
934 return self._context.lock_file_fd is not None
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
942 def __enter__(self) -> Self:
943 """
944 Acquire the lock.
946 :returns: the lock object
948 """
949 self.acquire()
950 return self
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)
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)
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)
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.
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.
1003 :returns: a context object that will unlock the file when the context is exited
1005 :raises Timeout: if fails to acquire lock within the timeout period
1007 .. code-block:: python
1009 # You can use this method in the context manager (recommended)
1010 with lock.acquire():
1011 pass
1013 # Or use an equivalent try-finally construct:
1014 lock.acquire()
1015 try:
1016 pass
1017 finally:
1018 lock.release()
1020 .. versionchanged:: 2.0.0
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.
1025 """
1026 self._raise_if_inherited()
1027 if timeout is None:
1028 timeout = self._context.timeout
1030 if blocking is None:
1031 blocking = self._context.blocking
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
1038 poll_interval = poll_interval if poll_interval is not None else self._context.poll_interval
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
1053 canonical = _canonical(self.lock_file)
1054 self._raise_if_would_deadlock(canonical, timeout=timeout, blocking=blocking)
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)
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()
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.
1102 :param force: If true, the lock counter is ignored and the lock is released in every case.
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
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)
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)
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
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
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
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
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)
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.
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)
1187 def _registry_key(self, canonical: str) -> Hashable:
1188 return canonical if (scope := self._deadlock_scope()) is None else (scope, canonical)
1190 @staticmethod
1191 def _deadlock_scope() -> Hashable | None:
1192 """
1193 Execution unit whose own hold would deadlock a new acquire.
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
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)
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
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()
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
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()
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 )
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)
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()
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 )
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)
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
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()
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)
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)
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()
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)
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
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
1424 @abstractmethod
1425 def _release(self) -> None:
1426 """Releases the lock and sets self._context.lock_file_fd to None."""
1427 raise NotImplementedError
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."""
1435 def __init__(self, lock: BaseFileLock | ReadWriteLock | SoftReadWriteLock) -> None:
1436 self.lock: BaseFileLock | ReadWriteLock | SoftReadWriteLock = lock
1438 def __enter__(self) -> BaseFileLock | ReadWriteLock | SoftReadWriteLock:
1439 return self.lock
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()
1453@dataclass
1454class FileLockContext:
1455 """Holds the context for a ``BaseFileLock`` object."""
1457 # A separate class so ThreadLocalFileContext can make the whole context thread-local.
1459 lock_file: str
1460 timeout: float
1461 mode: int
1462 blocking: bool
1463 poll_interval: float
1465 #: The lock lifetime in seconds; ``None`` means the lock never expires.
1466 lifetime: float | None = None
1468 #: File descriptor from os.open for the lock file; not None while the lock is held.
1469 lock_file_fd: int | None = None
1471 #: Registry token for the descriptor owned by this thread's context.
1472 lock_file_fd_token: int | None = None
1474 #: Identity captured by a backend that already inspected the descriptor.
1475 lock_file_fd_identity: tuple[int, int] | None = None
1477 #: Descriptor opened by a backend but not yet committed as the held lock.
1478 pending_lock_file_fd: int | None = None
1480 #: Identity captured for a descriptor whose acquisition has not committed.
1481 pending_lock_file_fd_identity: tuple[int, int] | None = None
1483 #: Depth of nested acquisitions; the lock is released only when it returns to 0.
1484 lock_counter: int = 0
1486 #: Canonical registry key captured when the first physical acquisition commits.
1487 lock_file_key: Hashable | None = None
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, ...] = ()
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
1497class ThreadLocalFileContext(FileLockContext, local):
1498 """A thread local version of the ``FileLockContext`` class."""
1501@dataclass(frozen=True)
1502class _OwnedDescriptor:
1503 fd: int
1504 creator_pid: int
1505 device: int | None
1506 inode: int | None
1509class _ForkTransitionContext(local):
1510 depth: int = 0
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()
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 = {}
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"})
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 )
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)
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()
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()
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)
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)
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
1622def _unregister_owned_descriptor(token: int) -> None: # pragma: needs fork
1623 with _fork_transition(), _FORK_STATE.registry_lock:
1624 _OWNED_DESCRIPTORS.pop(token, None)
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()))
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()
1670def _ensure_current_process() -> None:
1671 _reset_child_after_fork()
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)
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
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()
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 )
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)
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()
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)
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)
1794_register_fork_hooks()
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]