_soft.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. from __future__ import annotations
  2. import os
  3. import socket
  4. import sys
  5. import time
  6. from contextlib import suppress
  7. from errno import EACCES, EEXIST, EPERM, ESRCH
  8. from pathlib import Path
  9. from ._api import BaseFileLock
  10. from ._util import ensure_directory_exists, raise_on_not_writable_file
  11. _WIN_SYNCHRONIZE = 0x100000
  12. _WIN_ERROR_INVALID_PARAMETER = 87
  13. class SoftFileLock(BaseFileLock):
  14. """
  15. Portable file lock based on file existence.
  16. Unlike :class:`UnixFileLock <filelock.UnixFileLock>` and :class:`WindowsFileLock <filelock.WindowsFileLock>`, this
  17. lock does not use OS-level locking primitives. Instead, it creates the lock file with ``O_CREAT | O_EXCL`` and
  18. treats its existence as the lock indicator. This makes it work on any filesystem but leaves stale lock files behind
  19. if the process crashes without releasing the lock.
  20. To mitigate stale locks, the lock file contains the PID and hostname of the holding process. On contention, if the
  21. holder is on the same host and its PID no longer exists, the stale lock is broken automatically.
  22. """
  23. def _acquire(self) -> None:
  24. raise_on_not_writable_file(self.lock_file)
  25. ensure_directory_exists(self.lock_file)
  26. flags = (
  27. os.O_WRONLY # open for writing only
  28. | os.O_CREAT
  29. | os.O_EXCL # together with above raise EEXIST if the file specified by filename exists
  30. | os.O_TRUNC # truncate the file to zero byte
  31. )
  32. if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None:
  33. flags |= o_nofollow
  34. try:
  35. file_handler = os.open(self.lock_file, flags, self._open_mode())
  36. except OSError as exception:
  37. if not (
  38. exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32")
  39. ): # pragma: win32 no cover
  40. raise
  41. if exception.errno == EEXIST and sys.platform != "win32": # pragma: win32 no cover
  42. self._try_break_stale_lock()
  43. else:
  44. self._write_lock_info(file_handler)
  45. self._context.lock_file_fd = file_handler
  46. def _try_break_stale_lock(self) -> None:
  47. with suppress(OSError):
  48. content = Path(self.lock_file).read_text(encoding="utf-8")
  49. lines = content.strip().splitlines()
  50. if len(lines) != 2: # noqa: PLR2004
  51. return
  52. pid_str, hostname = lines
  53. if hostname != socket.gethostname():
  54. return
  55. pid = int(pid_str)
  56. if self._is_process_alive(pid):
  57. return
  58. break_path = f"{self.lock_file}.break.{os.getpid()}"
  59. Path(self.lock_file).rename(break_path)
  60. Path(break_path).unlink()
  61. @staticmethod
  62. def _is_process_alive(pid: int) -> bool:
  63. if sys.platform == "win32": # pragma: win32 cover
  64. import ctypes # noqa: PLC0415
  65. kernel32 = ctypes.windll.kernel32
  66. handle = kernel32.OpenProcess(_WIN_SYNCHRONIZE, 0, pid)
  67. if handle:
  68. kernel32.CloseHandle(handle)
  69. return True
  70. return kernel32.GetLastError() != _WIN_ERROR_INVALID_PARAMETER
  71. try:
  72. os.kill(pid, 0)
  73. except OSError as exc:
  74. if exc.errno == ESRCH:
  75. return False
  76. if exc.errno == EPERM:
  77. return True
  78. raise
  79. return True
  80. @staticmethod
  81. def _write_lock_info(fd: int) -> None:
  82. with suppress(OSError):
  83. os.write(fd, f"{os.getpid()}\n{socket.gethostname()}\n".encode())
  84. def _release(self) -> None:
  85. assert self._context.lock_file_fd is not None # noqa: S101
  86. os.close(self._context.lock_file_fd)
  87. self._context.lock_file_fd = None
  88. if sys.platform == "win32":
  89. self._windows_unlink_with_retry()
  90. else:
  91. with suppress(OSError):
  92. Path(self.lock_file).unlink()
  93. def _windows_unlink_with_retry(self) -> None:
  94. max_retries = 10
  95. retry_delay = 0.001
  96. for attempt in range(max_retries):
  97. # Windows doesn't immediately release file handles after close, causing EACCES/EPERM on unlink
  98. try:
  99. Path(self.lock_file).unlink()
  100. except OSError as exc: # noqa: PERF203
  101. if exc.errno not in {EACCES, EPERM}:
  102. return
  103. if attempt < max_retries - 1:
  104. time.sleep(retry_delay)
  105. retry_delay *= 2
  106. else:
  107. return
  108. __all__ = [
  109. "SoftFileLock",
  110. ]