File size: 7,833 Bytes
5e9fb2f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | from __future__ import annotations
import os
import socket
import sys
import time
from contextlib import suppress
from errno import EACCES, EEXIST, EPERM, ESRCH
from pathlib import Path
from ._api import BaseFileLock
from ._util import ensure_directory_exists, raise_on_not_writable_file
_WIN_SYNCHRONIZE = 0x100000
_WIN_ERROR_INVALID_PARAMETER = 87
_WIN_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
_MALFORMED_LOCK_AGE_THRESHOLD = 2.0
class SoftFileLock(BaseFileLock):
"""
Portable file lock based on file existence.
Unlike :class:`UnixFileLock <filelock.UnixFileLock>` and :class:`WindowsFileLock <filelock.WindowsFileLock>`, this
lock does not use OS-level locking primitives. Instead, it creates the lock file with ``O_CREAT | O_EXCL`` and
treats its existence as the lock indicator. This makes it work on any filesystem but leaves stale lock files behind
if the process crashes without releasing the lock.
To mitigate stale locks, the lock file contains the PID and hostname of the holding process. On contention, if the
holder is on the same host and its PID no longer exists, the stale lock is broken automatically.
"""
def _acquire(self) -> None:
raise_on_not_writable_file(self.lock_file)
ensure_directory_exists(self.lock_file)
flags = (
os.O_WRONLY # open for writing only
| os.O_CREAT
| os.O_EXCL # together with above raise EEXIST if the file specified by filename exists
| os.O_TRUNC # truncate the file to zero byte
)
if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None:
flags |= o_nofollow
try:
file_handler = os.open(self.lock_file, flags, self._open_mode())
except OSError as exception:
if not (
exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32")
): # pragma: win32 no cover
raise
self._try_break_stale_lock()
else:
self._write_lock_info(file_handler)
self._context.lock_file_fd = file_handler
def _try_break_stale_lock(self) -> None:
with suppress(OSError, ValueError):
lock_path = Path(self.lock_file)
stat_result = lock_path.stat()
content = lock_path.read_text(encoding="utf-8")
lines = content.strip().splitlines()
if len(lines) not in {2, 3}:
if time.time() - stat_result.st_mtime >= _MALFORMED_LOCK_AGE_THRESHOLD:
self._evict_lock_file()
return
pid_str, hostname = lines[0], lines[1]
creation_time_str = lines[2] if len(lines) == 3 else None # noqa: PLR2004
if hostname != socket.gethostname():
return
pid = int(pid_str)
if self._is_process_alive(pid):
if sys.platform == "win32" and creation_time_str is not None: # pragma: win32 cover
stored = int(creation_time_str)
actual = self._get_process_creation_time(pid)
if actual is not None and actual != stored:
pass # PID recycled, fall through to evict
else:
return # same process or can't verify — don't evict
else:
return
self._evict_lock_file()
def _evict_lock_file(self) -> None:
break_path = f"{self.lock_file}.break.{os.getpid()}"
Path(self.lock_file).rename(break_path)
Path(break_path).unlink()
@staticmethod
def _is_process_alive(pid: int) -> bool:
if sys.platform == "win32": # pragma: win32 cover
import ctypes # noqa: PLC0415
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(_WIN_SYNCHRONIZE, 0, pid)
if handle:
kernel32.CloseHandle(handle)
return True
return kernel32.GetLastError() != _WIN_ERROR_INVALID_PARAMETER
try:
os.kill(pid, 0)
except OSError as exc:
if exc.errno == ESRCH:
return False
if exc.errno == EPERM:
return True
raise
return True
@staticmethod
def _get_process_creation_time(pid: int) -> int | None:
"""Return the process creation FILETIME as an integer on Windows, ``None`` otherwise."""
if sys.platform != "win32": # pragma: win32 no cover
return None
import ctypes # pragma: win32 cover # noqa: PLC0415
from ctypes import wintypes # noqa: PLC0415
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(_WIN_PROCESS_QUERY_LIMITED_INFORMATION, 0, pid)
if not handle:
return None
try:
creation = wintypes.FILETIME()
exit_time = wintypes.FILETIME()
kernel_time = wintypes.FILETIME()
user_time = wintypes.FILETIME()
if not kernel32.GetProcessTimes(
handle,
ctypes.byref(creation),
ctypes.byref(exit_time),
ctypes.byref(kernel_time),
ctypes.byref(user_time),
):
return None
finally:
kernel32.CloseHandle(handle)
return (creation.dwHighDateTime << 32) | creation.dwLowDateTime
@staticmethod
def _write_lock_info(fd: int) -> None:
with suppress(OSError):
info = f"{os.getpid()}\n{socket.gethostname()}\n"
if sys.platform == "win32" and (ct := SoftFileLock._get_process_creation_time(os.getpid())) is not None:
info += f"{ct}\n"
os.write(fd, info.encode())
@property
def pid(self) -> int | None:
"""
The PID of the process holding this lock, read from the lock file.
:returns: the PID as an integer, or ``None`` if the lock file does not exist or cannot be parsed
"""
try:
content = Path(self.lock_file).read_text(encoding="utf-8")
lines = content.strip().splitlines()
if lines:
return int(lines[0])
except (OSError, ValueError):
pass
return None
@property
def is_lock_held_by_us(self) -> bool:
"""
Whether this lock is held by the current process.
:returns: ``True`` if the lock file exists and contains the current process's PID
"""
return self.pid == os.getpid()
def break_lock(self) -> None:
"""Forcibly break the lock by removing the lock file, regardless of who holds it."""
with suppress(OSError):
Path(self.lock_file).unlink()
def _release(self) -> None:
assert self._context.lock_file_fd is not None # noqa: S101
os.close(self._context.lock_file_fd)
self._context.lock_file_fd = None
if sys.platform == "win32":
self._windows_unlink_with_retry()
else:
with suppress(OSError):
Path(self.lock_file).unlink()
def _windows_unlink_with_retry(self) -> None:
max_retries = 10
retry_delay = 0.001
for attempt in range(max_retries):
# Windows doesn't immediately release file handles after close, causing EACCES/EPERM on unlink
try:
Path(self.lock_file).unlink()
except OSError as exc: # noqa: PERF203
if exc.errno not in {EACCES, EPERM}:
return
if attempt < max_retries - 1:
time.sleep(retry_delay)
retry_delay *= 2
else:
return
__all__ = [
"SoftFileLock",
]
|