Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/filelock/_soft.py: 20%
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
3__lazy_modules__ = [
4 "contextlib",
5 "filelock._identity",
6 "filelock._soft_protocol",
7 "filelock._util",
8 "itertools",
9 "pathlib",
10 "re",
11 "secrets",
12 "typing",
13]
15import os
16import re
17import secrets
18import stat
19import sys
20import threading
21import time
22from contextlib import suppress
23from errno import EACCES, EEXIST, EPERM
24from itertools import islice
25from pathlib import Path
26from typing import Final
28from filelock._api import BaseFileLock, _raise_grouped_errors
29from filelock._identity import host_name, owner_is_current_process, owner_is_stale, process_start_token
30from filelock._soft_protocol import STRICT_SOFT_SENTINEL_RECORD
31from filelock._util import (
32 break_lock_file,
33 exchange_names,
34 exchange_supported,
35 pin_inode,
36 raise_on_not_writable_file,
37 remove_own_file,
38 write_all,
39)
41if sys.platform == "win32": # pragma: win32 cover
42 from filelock._windows import open_read_without_oplock_wait
43 from filelock._windows import unlink_without_oplock_wait as unlink_path
44else: # pragma: win32 no cover
45 from filelock._util import unlink_path
47MALFORMED_LOCK_AGE_THRESHOLD: Final[float] = 2.0
48_MAX_LOCK_FILE_SIZE: Final[int] = 1024
49_UNLINK_MAX_RETRIES: Final[int] = 10
50_MARKER_WITH_START_TOKEN_LINE_COUNT: Final[int] = 3
51#: Contenders act on the record in the marker, so no other account may write it, whatever the mode or umask.
52_OTHERS_WRITE: Final[int] = stat.S_IWGRP | stat.S_IWOTH
53#: All a SoftFileLock before 3.22 writes, and what a current one shows between creating its marker and writing it.
54_RECORDLESS_MARKER: Final[str] = ""
55_CLAIMS_IN_PROGRESS: Final[set[str]] = set()
56_CLAIMS_LOCK: Final[threading.Lock] = threading.Lock()
59class SoftFileLock(BaseFileLock):
60 """
61 Cooperative file lock based on a shared existence marker.
63 Unlike :class:`UnixFileLock <filelock.UnixFileLock>` and :class:`WindowsFileLock <filelock.WindowsFileLock>`, this
64 lock does not use OS-level locking primitives. Instead, it creates the lock file with ``O_CREAT | O_EXCL`` and
65 treats its existence as the lock indicator. The filesystem must provide coherent exclusive creation and directory
66 updates to each participating process. A crash can leave the marker behind.
68 The marker contains the holder's PID and hostname. A contender may remove it when it can no longer find a same-host
69 process with that PID. A configured :attr:`~filelock.BaseFileLock.lifetime` also permits removal based on marker
70 age, including while the holder remains alive. Age-based expiry can overlap protected operations and does not
71 provide strict mutual exclusion.
73 """
75 #: Existence locks reclaim by unlinking a pathname, so an age-based lease may break one; a native inode lock cannot.
76 _lifetime_supported: bool = True
78 #: Age-based expiry preserves historical behavior but does not provide strict mutual exclusion.
79 _lifetime_replacements: tuple[str, str] | None = ("StrictSoftFileLock", "SoftFileLease")
81 #: An existence lock unlinks its marker to release, so it cannot promise to keep the pathname.
82 _preserve_lock_file_supported: bool = False
84 #: An existence lock keeps protocol state in its marker, so it cannot lend the descriptor to an on_acquired hook.
85 _on_acquired_supported: bool = False
87 #: The marker's (mtime, inode, ctime) when this instance last saw it change, and when, on its monotonic clock.
88 _marker_seen: tuple[tuple[float, int, int], float] | None = None
90 #: The identity of a marker a Windows release relinquished but has not unlinked yet.
91 _marker_cleanup: tuple[int, int] | None = None
93 #: The descriptor of that marker while its close has not run yet, as a list so one C call can take it.
94 _marker_fds: list[int]
96 def _acquire(self) -> None:
97 self._finish_marker_cleanup()
98 raise_on_not_writable_file(self._lock_path)
99 # O_CREAT | O_EXCL makes the create fail with EEXIST when the file already exists, so a successful open
100 # means this process now holds the lock.
101 flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC
102 if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: # pragma: needs o-nofollow
103 flags |= o_nofollow
104 try:
105 fd = os.open(self._lock_path, flags, self._open_mode() & ~_OTHERS_WRITE)
106 except OSError as exception:
107 if not (
108 exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32")
109 ): # pragma: win32 no cover
110 raise
111 self._try_break_stale_lock()
112 return
113 # Publish held state only once the record is on disk. On failure, cancellation included, close the descriptor
114 # and unlink the path only while it still names the file we opened: after an expired lease a successor's
115 # marker may sit at the same path. The guard starts at the open, so a signal handler that raises before
116 # _mark_descriptor_owned leaves no marker behind.
117 identity: tuple[int, int] | None = None
118 try:
119 identity = _file_identity(os.fstat(fd))
120 self._mark_descriptor_pending(fd)
121 self._apply_explicit_mode(fd)
122 self._write_lock_info(fd)
123 self._mark_descriptor_owned(fd, identity)
124 except BaseException:
125 self._mark_descriptor_released()
126 try:
127 if identity is None:
128 # The first read failed, as a network mount reporting EIO or an interrupt landing mid-call does, and
129 # this attempt still owns the descriptor it created the marker through, so a retry usually answers.
130 # Without the identity the Windows unlink below cannot recognize the marker, and the empty marker it
131 # leaves blocks the path for good, since no contender breaks one.
132 with suppress(OSError):
133 identity = _file_identity(os.fstat(fd))
134 if sys.platform != "win32": # pragma: win32 no cover
135 # Before the close: our open descriptor keeps the inode allocated, so a successor's marker cannot
136 # carry its number.
137 with suppress(OSError):
138 remove_own_file(self._lock_path, fd)
139 finally:
140 try:
141 os.close(fd)
142 finally:
143 if sys.platform == "win32": # pragma: win32 cover
144 self._unlink_held_marker(identity)
145 raise
147 def _apply_explicit_mode(self, fd: int) -> None:
148 # os.open filters the mode through the umask, so an explicit mode lands as given only through fchmod, the way
149 # UnixFileLock applies it. Windows maps the mode to a read-only bit that os.open already set.
150 if self.has_explicit_mode and sys.platform != "win32": # pragma: win32 no cover
151 with suppress(PermissionError):
152 os.fchmod(fd, self._context.mode & ~_OTHERS_WRITE)
154 def _try_break_stale_lock(self) -> None:
155 with suppress(OSError, ValueError), pin_inode(self._lock_path) as pinned:
156 content, marker = _read_lock_file(self._lock_path)
157 if pinned not in {None, marker.st_ino}: # pragma: win32 no cover # Windows pins nothing
158 return # another node replaced the pinned one before the read, and nothing keeps its number from reuse
159 if content == STRICT_SOFT_SENTINEL_RECORD: # pragma: needs hard-link
160 return
161 # Such a marker is held for as long as its owner runs, so being unchanged proves nobody gone.
162 if content == _RECORDLESS_MARKER:
163 return
164 holder = _parse_lock_holder(content)
166 if holder is None:
167 # Unparsable: wrong line count, a non-integer PID or start token, empty, oversized or not UTF-8.
168 # Self-heal only once the file is clearly not a half-written fresh lock (a peer between O_EXCL and
169 # _write_lock_info), so the brief create-then-write window is never mistaken for a stale lock.
170 if self._marker_unchanged_for(marker) >= MALFORMED_LOCK_AGE_THRESHOLD:
171 self._take_over(marker, _placeholder_breaker(content))
172 return
174 if owner_is_stale(*holder):
175 self._take_over(marker, holder[0])
177 def _try_break_expired_lock(self) -> None:
178 if (lifetime := self._context.lifetime) is None:
179 return
180 with suppress(OSError), pin_inode(self._lock_path) as pinned:
181 # _read_lock_file takes the lstat, not stat: a symlink swapped in at the path must report its own mtime.
182 content, marker = _read_lock_file(self._lock_path)
183 if pinned not in {None, marker.st_ino} or time.time() - marker.st_mtime < lifetime:
184 return
185 holder = self._recorded_holder(content)
186 self._take_over(marker, None if holder is None else holder[0])
188 def _take_over(self, marker: os.stat_result, breaker_pid: int | None) -> None:
189 # Swap the judged marker for a complete marker of ours in one exchange, so the lock path never stands empty.
190 # Once the file swapped out proves to be the one judged, this lock holds the path; the caller's pin keeps that
191 # file's inode number from naming anything else. Only a peer's exchange can move our marker after that, and
192 # the peer puts it back because what it took is not the file it judged. Every failure before the handoff takes
193 # our marker off the path and puts back what the exchange took. Without an exchange, the portable break decides.
194 if exchange_supported(): # pragma: win32 no cover
195 self._take_over_by_exchange(marker, breaker_pid)
196 else:
197 break_lock_file(self._lock_path, marker.st_mtime, marker.st_ino)
199 def _take_over_by_exchange(self, marker: os.stat_result, breaker_pid: int | None) -> None: # pragma: win32 no cover
200 if (current := os.lstat(self._lock_path)).st_mtime != marker.st_mtime or current.st_ino != marker.st_ino:
201 return
202 if breaker_pid is not None and self._restore_stranded_marker(breaker_pid, marker):
203 return
204 aside: Final = f"{self._lock_path}.break.{os.getpid()}.{secrets.token_hex(16)}"
205 flags: Final = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
206 fd: Final = os.open(aside, flags, self._open_mode() & ~_OTHERS_WRITE)
207 self._mark_descriptor_pending(fd)
208 exchanged = held = False
209 try:
210 identity: Final = _file_identity(os.fstat(fd))
211 self._apply_explicit_mode(fd)
212 self._write_lock_info(fd)
213 if not exchange_names(aside, self._lock_path):
214 return # NFS, SMB and the like refuse it; renaming the marker aside would empty the path
215 exchanged = True
216 if (taken := os.lstat(aside)).st_mtime != marker.st_mtime or taken.st_ino != marker.st_ino:
217 return # a peer recreated the marker, which the cleanup below swaps back
218 Path(aside).unlink()
219 exchanged = False
220 self._mark_descriptor_owned(fd, identity)
221 held = True
222 finally:
223 if not held:
224 self._abandon_take_over(fd, aside, put_back=exchanged)
226 def _restore_stranded_marker(self, breaker_pid: int, marker: os.stat_result) -> bool: # pragma: win32 no cover
227 # A breaker killed between its exchange and the put-back leaves its own marker at the path and the live
228 # holder's marker under its break name. A recoverer first renames that record to a name of its own. Only one
229 # contender wins the rename, so two of them never exchange the same record and reverse each other. A record
230 # a dead recoverer holds is claimable again. A claim of this process that no thread is working on was
231 # abandoned by a failed put-back, and the next poll takes it up again. Where the marker cannot be put back
232 # the lock counts as held, as it does wherever no exchange is possible.
233 if (candidates := self._stranded_candidates(breaker_pid)) is None:
234 return True
235 return any(self._recover_claimed(path, breaker_pid, marker, adopt=abandoned) for path, abandoned in candidates)
237 def _stranded_candidates(self, breaker_pid: int) -> list[tuple[str, bool]] | None: # pragma: win32 no cover
238 directory, name = os.path.split(self._lock_path)
239 stranded: Final = re.compile(rf"{re.escape(name)}\.break\.{breaker_pid}\.[0-9a-f]{{32}}")
240 # [0-9], not \d: a str pattern's \d takes any Unicode digit, and int() reads fullwidth ones as another number.
241 claimed: Final = re.compile(rf"{re.escape(name)}\.restore\.([0-9]+)\.[0-9a-f]{{32}}")
242 candidates: list[tuple[str, bool]] = []
243 with os.scandir(directory or os.curdir) as entries:
244 for entry in entries:
245 if not entry.is_file(follow_symlinks=False):
246 continue
247 path = str(Path(directory) / entry.name)
248 if stranded.fullmatch(entry.name):
249 candidates.append((path, False))
250 elif (found := claimed.fullmatch(entry.name)) and 1 <= (recoverer := int(found[1])) <= 2**31 - 1:
251 # No filelock writes a claim outside the pid range; os.kill would raise OverflowError on one.
252 ours = recoverer == os.getpid()
253 if not (ours or owner_is_stale(recoverer, host_name(), None)):
254 return None
255 candidates.append((path, ours))
256 return sorted(candidates)
258 def _recover_claimed(
259 self, path: str, breaker_pid: int, marker: os.stat_result, *, adopt: bool
260 ) -> bool: # pragma: win32 no cover
261 # One try/finally owns the registration from the first step to the last, so no interruption can leave it set.
262 # Registered before the rename publishes the name, so a thread that scans meanwhile sees a claim in progress.
263 own: Final = path if adopt else f"{self._lock_path}.restore.{os.getpid()}.{secrets.token_hex(16)}"
264 if not _begin_claim(own):
265 return True
266 try:
267 if not adopt:
268 try:
269 Path(path).rename(own)
270 except OSError: # a peer that won the rename now holds the record, so the lock counts as held
271 return True
272 return self._restore_claimed(own, breaker_pid, marker)
273 finally:
274 _end_claim(own)
276 def _restore_claimed(self, own: str, breaker_pid: int, marker: os.stat_result) -> bool: # pragma: win32 no cover
277 try:
278 content, _ = _read_lock_file(own)
279 except OSError:
280 self._return_claim(own, breaker_pid)
281 return True
282 if not self._marker_is_live(content, breaker_pid):
283 with suppress(OSError):
284 Path(own).unlink()
285 return False
286 # The inode of the marker judged dead must still be at the path, or a peer has acted since.
287 with suppress(OSError):
288 current = os.lstat(self._lock_path)
289 if (current.st_ino, current.st_mtime) == (marker.st_ino, marker.st_mtime) and exchange_names(
290 own, self._lock_path
291 ):
292 if os.lstat(own).st_ino == marker.st_ino:
293 Path(own).unlink()
294 return True
295 # A peer replaced the path after the check, so the exchange took its marker: swap it straight back.
296 exchange_names(own, self._lock_path)
297 self._return_claim(own, breaker_pid)
298 return True
300 def _return_claim(self, own: str, breaker_pid: int) -> None: # pragma: win32 no cover
301 # A refused rename leaves the claim under this process's PID, and the next poll retries it.
302 with suppress(OSError):
303 Path(own).rename(f"{self._lock_path}.break.{breaker_pid}.{secrets.token_hex(16)}")
305 @classmethod
306 def _marker_is_live(cls, content: str | None, breaker_pid: int) -> bool: # pragma: win32 no cover
307 if content == STRICT_SOFT_SENTINEL_RECORD: # pragma: needs hard-link
308 return True
309 if content == _RECORDLESS_MARKER:
310 # An empty marker is a pre-3.22 holder for as long as it exists, as the stale check treats it.
311 return True
312 # The breaker's own marker names its PID on this host; the same number on another host is a holder.
313 return (
314 (holder := _parse_lock_holder(content)) is not None
315 and (holder[0] != breaker_pid or holder[1] != host_name())
316 and not owner_is_stale(*holder)
317 )
319 def _abandon_take_over(self, fd: int, aside: str, *, put_back: bool) -> None: # pragma: win32 no cover
320 self._mark_descriptor_released()
321 try:
322 if put_back:
323 exchange_names(aside, self._lock_path)
324 finally:
325 try:
326 # Whichever name still holds our marker loses it: the descriptor keeps its inode number ours.
327 own: Final = os.fstat(fd)
328 for name in (self._lock_path, aside):
329 with suppress(FileNotFoundError):
330 if os.path.samestat(os.lstat(name), own):
331 Path(name).unlink()
332 finally:
333 os.close(fd)
335 def _marker_unchanged_for(self, marker: os.stat_result) -> float:
336 # The mtime is the writer's or file server's clock, so against time.time() a contender whose clock runs ahead
337 # would expire a fresh marker. Only watch it for change, timed on this process's monotonic clock. The owner of a
338 # file sets its mtime, and ext4 reuses a freed inode number, so a new marker can repeat both. The change time
339 # cannot be set and moves with every create, which keeps a new marker from inheriting an old one's age.
340 now: Final = time.monotonic()
341 key: Final = marker.st_mtime, marker.st_ino, marker.st_ctime_ns
342 if (seen := self._marker_seen) is None or seen[0] != key:
343 self._marker_seen = key, now
344 return 0.0
345 return now - seen[1]
347 def _unprobeable_holder(self) -> str | None:
348 # This host cannot prove such a holder dead, so whoever decides whether it is gone needs its name.
349 with suppress(OSError, ValueError):
350 if (content := _read_lock_file(self._lock_path)[0]) == _RECORDLESS_MARKER:
351 return "an owner that recorded no PID (a filelock release before 3.22, or one still writing its record)"
352 if (holder := self._recorded_holder(content)) and holder[1] != host_name():
353 return f"pid {holder[0]} on {holder[1]}"
354 return None
356 @classmethod
357 def _recorded_holder(cls, content: str | None) -> tuple[int, str] | None:
358 return None if (holder := _parse_lock_holder(content)) is None else holder[:2]
360 @staticmethod
361 def _write_lock_info(fd: int) -> None:
362 # No suppression: a write failure must reach the acquisition rollback so it never publishes a half-written
363 # marker as held state. The optional third line is this process's start token, absent when the platform
364 # exposes no proven start time, in which case a reader falls back to PID-only liveness.
365 info = f"{os.getpid()}\n{host_name()}\n"
366 if (token := process_start_token(os.getpid())) is not None:
367 info += f"{token}\n"
368 write_all(fd, info.encode())
370 @property
371 def pid(self) -> int | None:
372 """
373 The PID of the process holding this lock, read from the lock file.
375 :returns: the PID as an integer, or ``None`` if the lock file does not exist or cannot be parsed
377 """
378 with suppress(OSError, ValueError):
379 holder = _parse_lock_holder(_read_lock_file(self._lock_path)[0])
380 if holder is not None:
381 return holder[0]
382 return None
384 @property
385 def is_lock_held_by_us(self) -> bool:
386 """
387 Whether this lock is held by the current process.
389 :returns: ``True`` if the lock file exists and names the current process's PID, hostname and, when recorded,
390 start token
392 """
393 with suppress(OSError, ValueError):
394 if (holder := _parse_lock_holder(_read_lock_file(self._lock_path)[0])) is not None:
395 return owner_is_current_process(*holder)
396 return False
398 def break_lock(self) -> None:
399 """Forcibly break the lock by removing the lock file, regardless of who holds it."""
400 with suppress(OSError):
401 Path(self._lock_path).unlink()
403 def _release_current(self, *, force: bool) -> None:
404 try:
405 super()._release_current(force=force)
406 finally:
407 # A release a handler cut short after the descriptor closed still owes the unlink on Windows.
408 self._finish_marker_cleanup()
410 def _release(self) -> None:
411 fd = self._context.lock_file_fd
412 assert fd is not None # ruff:ignore[assert] # _release runs only while held, so the descriptor is set
413 # Capture the held file's identity before closing so cleanup can refuse to unlink a successor's marker. A
414 # supported lifetime lease lets a peer break our expired marker and create its own at this path before we
415 # release; unlinking by path alone would then delete the successor's lock.
416 identity: tuple[int, int] | None = None
417 with suppress(OSError):
418 identity = _file_identity(os.fstat(fd))
419 if sys.platform == "win32": # pragma: win32 cover
420 self._release_then_unlink(fd, identity)
421 else: # pragma: win32 no cover
422 # Our open descriptor keeps the inode allocated, so its number cannot name a successor's marker yet, and an
423 # interrupt before the unlink lands leaves the lock held for the retried release to finish.
424 unlinked: Final = self._unlink_open_marker(fd, identity)
425 # A failed close may already have released and recycled the descriptor number. Relinquish it before the
426 # one close attempt so no later release can close an unrelated descriptor that reused the same integer.
427 self._mark_descriptor_released()
428 try:
429 self._close_released_fd(fd, default_suppresses=False)
430 finally:
431 # A share that refuses to delete an open file gets the unlink after the close; the marker still exists
432 # until then, so its inode cannot be reused either.
433 if not unlinked:
434 self._unlink_held_marker(identity)
436 def _release_then_unlink(self, fd: int, identity: tuple[int, int] | None) -> None: # pragma: win32 cover
437 # Windows cannot unlink an open file, so the unlink outlives the hold until it lands, here or on a later acquire
438 # or release. Only the gate's holder touches the pending record, so two threads never overwrite each other's.
439 with self._transition_lock:
440 self._marker_fds = [fd]
441 self._marker_cleanup = identity
442 self._mark_descriptor_released()
443 try:
444 self._close_marker_fd()
445 # Marker cleanup must also run for control-flow exceptions, and both failures must remain observable.
446 except BaseException as close_error:
447 try:
448 self._finish_marker_cleanup()
449 except BaseException as cleanup_error: # ruff:ignore[blind-except] # keep control-flow failures
450 _raise_grouped_errors(
451 "lock descriptor close and marker cleanup both failed",
452 close_error,
453 cleanup_error,
454 )
455 raise
456 self._finish_marker_cleanup()
458 def _marker_names(self) -> tuple[str, ...]:
459 # After a rename of the lock's directory only the caller's spelling, resolved through the working directory,
460 # still reaches the marker this hold opened.
461 return tuple(dict.fromkeys((self._lock_path, self.lock_file)))
463 def _unlink_open_marker(self, fd: int, identity: tuple[int, int] | None) -> bool: # pragma: win32 no cover
464 if identity is None:
465 return True
466 replaced = False
467 for name in self._marker_names():
468 try:
469 if _file_identity(os.lstat(name)) == identity:
470 Path(name).unlink()
471 return True
472 replaced = True
473 except FileNotFoundError:
474 continue
475 except OSError:
476 return False
477 if replaced:
478 try:
479 _leave_released_record(fd)
480 except OSError:
481 return False
482 return True
484 def _finish_marker_cleanup(self) -> None:
485 # Never waits: whoever holds the gate is acquiring or releasing this lock and finishes the cleanup itself.
486 if self._marker_cleanup is not None and self._transition_lock.acquire(blocking=False): # pragma: win32 cover
487 try:
488 # A close error here belongs to the release an interrupt cut short, not to this caller.
489 with suppress(OSError):
490 self._close_marker_fd()
491 if (identity := self._marker_cleanup) is not None: # pragma: no branch # the gate's holder clears it
492 self._windows_unlink_if_ours(identity)
493 finally:
494 self._transition_lock.release()
496 def _close_marker_fd(self) -> None: # pragma: win32 cover
497 # Popping and closing is one chain of C calls that no signal handler can split, so a retried release closes the
498 # descriptor at most once; a second close could hit an unrelated file that reused the number.
499 try:
500 list(map(os.close, islice(iter(vars(self).setdefault("_marker_fds", []).pop, None), 1)))
501 except IndexError:
502 return
503 except OSError:
504 if not self._suppresses_close_error(default_suppresses=False):
505 raise
507 def _unlink_held_marker(self, identity: tuple[int, int] | None) -> None:
508 if sys.platform == "win32": # pragma: win32 cover
509 if identity is not None:
510 self._windows_unlink_if_ours(identity)
511 else: # pragma: win32 no cover
512 for name in self._marker_names():
513 with suppress(OSError):
514 if _file_identity(os.lstat(name)) == identity:
515 Path(name).unlink()
516 return
518 def _windows_unlink_if_ours(self, identity: tuple[int, int]) -> None: # pragma: win32 cover
519 retry_delay = 0.001
520 for attempt in range(_UNLINK_MAX_RETRIES):
521 # Windows doesn't immediately release file handles after close, causing EACCES/EPERM on unlink. Recheck
522 # identity each attempt: a failed unlink leaves a window for a successor to replace the marker at this path.
523 try:
524 for name in self._marker_names():
525 with suppress(FileNotFoundError):
526 if _file_identity(os.lstat(name)) == identity:
527 # Forgotten before the unlink: Windows does not promise unique file IDs, so no retry may
528 # reach a successor that reused ours.
529 self._marker_cleanup = None
530 unlink_path(name)
531 break
532 except OSError as exc:
533 if exc.errno in {EACCES, EPERM}:
534 # The file is still ours, so the next acquire or release tries again if these attempts run out.
535 self._marker_cleanup = identity
536 if attempt < _UNLINK_MAX_RETRIES - 1:
537 time.sleep(retry_delay)
538 retry_delay *= 2
539 continue
540 self._marker_cleanup = None
541 return
544def _file_identity(st: os.stat_result) -> tuple[int, int]:
545 # (st_dev, st_ino) names the concrete inode behind a path, so a marker recreated at the same pathname after an
546 # expired lease reads as a different file. CPython populates both on Windows from the volume serial and file index.
547 return st.st_dev, st.st_ino
550def _begin_claim(name: str | Path) -> bool: # pragma: win32 no cover
551 # Keyed by the file name alone: its random token identifies the claim, and the spelling of the directory varies.
552 with _CLAIMS_LOCK:
553 if (key := Path(name).name) in _CLAIMS_IN_PROGRESS:
554 return False
555 _CLAIMS_IN_PROGRESS.add(key)
556 return True
559def _end_claim(name: str | Path) -> None: # pragma: win32 no cover
560 with _CLAIMS_LOCK:
561 _CLAIMS_IN_PROGRESS.discard(Path(name).name)
564def _placeholder_breaker(content: str | None) -> int | None: # pragma: win32 no cover
565 # The placeholder a break leaves at the path while it works names the breaker that may have died there.
566 if content is None or not content.startswith(placeholder := "filelock/break\n") or not content.endswith("\n"):
567 return None
568 with suppress(ValueError):
569 return parse_decimal(content.removeprefix(placeholder)[:-1])
570 return None
573def _leave_released_record(fd: int) -> None:
574 # A peer's exchange holds our marker under its break name and will put it back. Overwriting the record lets
575 # contenders age it out instead of waiting on this live PID. The write comes before the truncate so the file is
576 # never empty.
577 if sys.platform != "win32": # pragma: win32 no cover
578 os.pwrite(fd, released := b"filelock/released\n", 0)
579 os.ftruncate(fd, len(released))
582def _read_lock_file(path: str) -> tuple[str | None, os.stat_result]:
583 # A legitimate lock file is always a regular file. Classify the path with lstat first, so any other node (symlink,
584 # FIFO, socket, device) is reported as a malformed lock the caller can evict, without an os.open that would follow
585 # a symlink, stall on a FIFO, or fail on a socket and leave acquisition wedged. The stat result still flows back
586 # for the identity-checked stale break. lstat, not stat, so a hostile symlink is never followed onto its target.
587 st = os.lstat(path)
588 if not stat.S_ISREG(st.st_mode): # pragma: needs fifo
589 return None, st
590 # Re-check on the opened handle: O_NOFOLLOW refuses a symlink swapped in after the lstat, O_NONBLOCK stops a FIFO
591 # swapped in from stalling the open, and the fstat catches any other non-regular replacement race before we read.
592 # The capped read stops a huge regular file (e.g. one filled from /dev/zero) from exhausting memory.
593 if sys.platform == "win32": # pragma: win32 cover
594 # os.open would wait for an oplock holder to answer its break, past the caller's deadline.
595 fd = open_read_without_oplock_wait(path)
596 else: # pragma: win32 no cover
597 fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
598 try:
599 st = os.fstat(fd)
600 if not stat.S_ISREG(st.st_mode): # pragma: no cover # only a non-regular node swapped in after the lstat
601 return None, st
602 data = os.read(fd, _MAX_LOCK_FILE_SIZE + 1)
603 finally:
604 os.close(fd)
605 if len(data) <= _MAX_LOCK_FILE_SIZE:
606 with suppress(UnicodeDecodeError):
607 return data.decode("utf-8"), st
608 return None, st
611def _parse_lock_holder(content: str | None) -> tuple[int, str, int | None] | None:
612 # A well-formed lock file is "<pid>\n<hostname>\n" with an optional "<start_token>\n" third line naming the
613 # holder's process start instant (a filelock 3.29 marker wrote this only on Windows; every platform writes it now).
614 # Anything else (wrong line count, a non-integer PID or start token, empty or unreadable content) is unparsable;
615 # returning None lets the caller treat it as a malformed lock to self-heal rather than a holder.
616 # Every writer since 3.22 ends each line with "\n", so a record without one was cut by a crash or read mid-write. As
617 # a holder it would name a foreign host no one reclaims, or a start token that lets a contender break a live lock.
618 if not content or not content.endswith("\n") or len(lines := content.strip().splitlines()) not in {2, 3}:
619 return None
620 try:
621 pid = parse_decimal(lines[0])
622 start_token = parse_decimal(lines[2]) if len(lines) == _MARKER_WITH_START_TOKEN_LINE_COUNT else None
623 except ValueError:
624 return None
625 # A pid outside the valid range is a malformed lock, not a holder. Without this, a non-positive pid
626 # reaches os.kill() where 0 / -1 mean "the caller's own process group / every process" so a dead
627 # holder reads as alive and the lock is never reclaimed, while an oversized pid raises OverflowError
628 # (not OSError/ValueError) out of the self-heal path. _parse_marker_bytes already enforces this range.
629 if not 1 <= pid <= 2**31 - 1:
630 return None
631 return pid, lines[1], start_token
634def parse_decimal(text: str) -> int:
635 """
636 Read a PID or start token as a writer formats it: ASCII digits only.
638 :raises ValueError: for anything else ``int()`` would accept, such as ``1_000``, surrounding whitespace, a sign or
639 non-ASCII digits; no filelock writes those
641 """
642 if not (text.isascii() and text.isdigit()):
643 msg = f"not a decimal integer: {text!r}"
644 raise ValueError(msg)
645 return int(text)
648__all__ = [
649 "MALFORMED_LOCK_AGE_THRESHOLD",
650 "SoftFileLock",
651 "parse_decimal",
652]