Portable fail-closed lock based on immutable owner claims.
| 93 | |
| 94 | |
| 95 | class StrictSoftFileLock(BaseFileLock): |
| 96 | """Portable fail-closed lock based on immutable owner claims.""" |
| 97 | |
| 98 | _preserve_lock_file_supported: bool = True |
| 99 | _on_acquired_supported: bool = False |
| 100 | #: Age cannot clear a strict claim: expiring one on a clock is the overlap the fail-closed contract exists to rule |
| 101 | #: out, so only force_break() removes it. |
| 102 | _lifetime_supported: bool = False |
| 103 | _lifetime_unsupported_reason: str = "a strict claim is never broken by age, only by force_break()" |
| 104 | #: The claim doorway publishes an intent and a held record per owner, so a shared instance must serialize them. |
| 105 | _serialize_transitions: bool = True |
| 106 | #: Contending processes each publish and rescan several files, so back their retries off across a jittered window |
| 107 | #: rather than let them collide on every poll. Seconds; keeps a waiter responsive once it wins. |
| 108 | _poll_backoff_cap: float = 0.05 |
| 109 | |
| 110 | def _acquire(self) -> None: |
| 111 | # Resolve once per acquisition, not per poll: a waiter on a relative path must keep publishing into the |
| 112 | # directory it started waiting in even when another thread changes the working directory mid-wait. |
| 113 | if (claim_root := self._context.claim_root) is None: |
| 114 | claim_root = self._context.claim_root = _canonical(self.lock_file) |
| 115 | lock_path = Path(claim_root) |
| 116 | coordination_directory = Path(f"{lock_path}{_COORDINATION_SUFFIX}") |
| 117 | claim_directory = coordination_directory / _CLAIM_DIRECTORY_NAME |
| 118 | ensure_directory_exists(os.fspath(lock_path)) |
| 119 | _ensure_protocol_directory(self.lock_file, coordination_directory) |
| 120 | _ensure_protocol_directory(self.lock_file, claim_directory) |
| 121 | if (sentinel_fd := _open_or_create_sentinel(self.lock_file, lock_path, self._open_mode())) is None: |
| 122 | return |
| 123 | try: |
| 124 | sentinel_identity = _file_identity(os.fstat(sentinel_fd)) |
| 125 | except BaseException as inspection_error: # preserve inspection and descriptor cleanup errors |
| 126 | try: |
| 127 | os.close(sentinel_fd) |
| 128 | except BaseException as close_error: # ruff:ignore[blind-except] # preserve inspection and descriptor cleanup errors |
| 129 | _raise_cleanup_errors("strict sentinel inspection cleanup failed", inspection_error, close_error) |
| 130 | raise |
| 131 | self._mark_descriptor_pending(sentinel_fd, sentinel_identity) |
| 132 | try: |
| 133 | self._attempt_doorway(claim_directory, sentinel_fd, sentinel_identity) |
| 134 | except BaseException: |
| 135 | if self._context.pending_lock_file_fd == sentinel_fd: |
| 136 | self._discard_doorway(sentinel_fd, sentinel_identity) |
| 137 | raise |
| 138 | |
| 139 | def _attempt_doorway(self, claim_directory: Path, sentinel_fd: int, sentinel_identity: tuple[int, int]) -> None: |
| 140 | if _read_existing_claims(self.lock_file, claim_directory): |
| 141 | self._discard_doorway(sentinel_fd, sentinel_identity) |
| 142 | return |
| 143 | |
| 144 | token = secrets.token_hex(_TOKEN_HEX_LENGTH // 2) |
| 145 | intent_name = _claim_name("intent", token) |
| 146 | intent_path = str(claim_directory / intent_name) |
| 147 | try: |
| 148 | publication_cleanup_error = _publish_record(intent_path, _claim_record(token), self._open_mode()) |
| 149 | except _PrivateRecordReclaimedError: |
| 150 | self._discard_doorway(sentinel_fd, sentinel_identity) |
| 151 | return |
| 152 | except (NotImplementedError, OSError) as error: |
no outgoing calls