Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/filelock/_soft.py: 22%
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 os
4import socket
5import sys
6import time
7from contextlib import suppress
8from errno import EACCES, EEXIST, EPERM, ESRCH
9from pathlib import Path
11from ._api import BaseFileLock
12from ._util import break_lock_file, ensure_directory_exists, raise_on_not_writable_file
14_WIN_SYNCHRONIZE = 0x100000
15_WIN_ERROR_INVALID_PARAMETER = 87
16_WIN_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
17_MALFORMED_LOCK_AGE_THRESHOLD = 2.0
18_MAX_LOCK_FILE_SIZE = 1024
21class SoftFileLock(BaseFileLock):
22 """
23 Portable file lock based on file existence.
25 Unlike :class:`UnixFileLock <filelock.UnixFileLock>` and :class:`WindowsFileLock <filelock.WindowsFileLock>`, this
26 lock does not use OS-level locking primitives. Instead, it creates the lock file with ``O_CREAT | O_EXCL`` and
27 treats its existence as the lock indicator. This makes it work on any filesystem but leaves stale lock files behind
28 if the process crashes without releasing the lock.
30 To mitigate stale locks, the lock file contains the PID and hostname of the holding process. On contention, if the
31 holder is on the same host and its PID no longer exists, the stale lock is broken automatically.
33 """
35 def _acquire(self) -> None:
36 raise_on_not_writable_file(self.lock_file)
37 ensure_directory_exists(self.lock_file)
38 flags = (
39 os.O_WRONLY # open for writing only
40 | os.O_CREAT
41 | os.O_EXCL # together with above raise EEXIST if the file specified by filename exists
42 | os.O_TRUNC # truncate the file to zero byte
43 )
44 if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None:
45 flags |= o_nofollow
46 try:
47 file_handler = os.open(self.lock_file, flags, self._open_mode())
48 except OSError as exception:
49 if not (
50 exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32")
51 ): # pragma: win32 no cover
52 raise
53 self._try_break_stale_lock()
54 else:
55 self._write_lock_info(file_handler)
56 self._context.lock_file_fd = file_handler
58 def _try_break_stale_lock(self) -> None:
59 with suppress(OSError, ValueError):
60 content, mtime, ino = _read_lock_file(self.lock_file)
61 holder = _parse_lock_holder(content)
63 if holder is None:
64 # Unparsable: wrong line count, a non-integer PID or creation time, empty, oversized or not UTF-8.
65 # Self-heal only once the file is clearly not a half-written fresh lock (a peer between O_EXCL and
66 # _write_lock_info), so the brief create-then-write window is never mistaken for a stale lock.
67 if time.time() - mtime >= _MALFORMED_LOCK_AGE_THRESHOLD:
68 break_lock_file(self.lock_file, mtime, ino)
69 return
71 pid, hostname, creation_time = holder
72 if hostname != socket.gethostname():
73 return
75 if self._is_process_alive(pid):
76 if sys.platform != "win32" or creation_time is None: # pragma: win32 no cover
77 return # same process, or no creation time to disambiguate a recycled PID — don't evict
78 actual = self._get_process_creation_time(pid) # pragma: win32 cover
79 if actual is None or actual == creation_time: # pragma: win32 cover
80 return # same process or can't verify — don't evict
81 # else: PID alive but creation time differs — the PID was recycled, so the lock is stale.
83 break_lock_file(self.lock_file, mtime, ino)
85 @staticmethod
86 def _is_process_alive(pid: int) -> bool:
87 if sys.platform == "win32": # pragma: win32 cover
88 import ctypes # noqa: PLC0415
90 kernel32 = ctypes.windll.kernel32
91 handle = kernel32.OpenProcess(_WIN_SYNCHRONIZE, 0, pid)
92 if handle:
93 kernel32.CloseHandle(handle)
94 return True
95 return kernel32.GetLastError() != _WIN_ERROR_INVALID_PARAMETER
96 try:
97 os.kill(pid, 0)
98 except OSError as exc:
99 if exc.errno == ESRCH:
100 return False
101 if exc.errno == EPERM:
102 return True
103 raise
104 return True
106 @staticmethod
107 def _get_process_creation_time(pid: int) -> int | None:
108 """Return the process creation FILETIME as an integer on Windows, ``None`` otherwise."""
109 if sys.platform != "win32": # pragma: win32 no cover
110 return None
111 import ctypes # pragma: win32 cover # noqa: PLC0415
112 from ctypes import wintypes # noqa: PLC0415
114 kernel32 = ctypes.windll.kernel32
115 handle = kernel32.OpenProcess(_WIN_PROCESS_QUERY_LIMITED_INFORMATION, 0, pid)
116 if not handle:
117 return None
118 try:
119 creation = wintypes.FILETIME()
120 exit_time = wintypes.FILETIME()
121 kernel_time = wintypes.FILETIME()
122 user_time = wintypes.FILETIME()
123 if not kernel32.GetProcessTimes(
124 handle,
125 ctypes.byref(creation),
126 ctypes.byref(exit_time),
127 ctypes.byref(kernel_time),
128 ctypes.byref(user_time),
129 ):
130 return None
131 finally:
132 kernel32.CloseHandle(handle)
133 return (creation.dwHighDateTime << 32) | creation.dwLowDateTime
135 @staticmethod
136 def _write_lock_info(fd: int) -> None:
137 with suppress(OSError):
138 info = f"{os.getpid()}\n{socket.gethostname()}\n"
139 if sys.platform == "win32" and (ct := SoftFileLock._get_process_creation_time(os.getpid())) is not None:
140 info += f"{ct}\n"
141 os.write(fd, info.encode())
143 @property
144 def pid(self) -> int | None:
145 """
146 The PID of the process holding this lock, read from the lock file.
148 :returns: the PID as an integer, or ``None`` if the lock file does not exist or cannot be parsed
150 """
151 with suppress(OSError, ValueError):
152 holder = _parse_lock_holder(_read_lock_file(self.lock_file)[0])
153 if holder is not None:
154 return holder[0]
155 return None
157 @property
158 def is_lock_held_by_us(self) -> bool:
159 """
160 Whether this lock is held by the current process.
162 :returns: ``True`` if the lock file exists and names the current process's PID and hostname
164 """
165 with suppress(OSError, ValueError):
166 holder = _parse_lock_holder(_read_lock_file(self.lock_file)[0])
167 if holder is not None:
168 pid, hostname, _ = holder
169 return pid == os.getpid() and hostname == socket.gethostname()
170 return False
172 def break_lock(self) -> None:
173 """Forcibly break the lock by removing the lock file, regardless of who holds it."""
174 with suppress(OSError):
175 Path(self.lock_file).unlink()
177 def _release(self) -> None:
178 assert self._context.lock_file_fd is not None # noqa: S101
179 os.close(self._context.lock_file_fd)
180 self._context.lock_file_fd = None
181 if sys.platform == "win32":
182 self._windows_unlink_with_retry()
183 else:
184 with suppress(OSError):
185 Path(self.lock_file).unlink()
187 def _windows_unlink_with_retry(self) -> None:
188 max_retries = 10
189 retry_delay = 0.001
190 for attempt in range(max_retries):
191 # Windows doesn't immediately release file handles after close, causing EACCES/EPERM on unlink
192 try:
193 Path(self.lock_file).unlink()
194 except OSError as exc: # noqa: PERF203
195 if exc.errno not in {EACCES, EPERM}:
196 return
197 if attempt < max_retries - 1:
198 time.sleep(retry_delay)
199 retry_delay *= 2
200 else:
201 return
204def _read_lock_file(path: str) -> tuple[str | None, float, int]:
205 # The lock file is created with O_EXCL | O_NOFOLLOW, so a symlink here is a hostile replacement and must
206 # not be followed. O_NONBLOCK keeps an attacker-placed FIFO from stalling the open (O_NOFOLLOW alone only
207 # rejects a symlink, not a real FIFO at the path), and the capped read stops a huge file (e.g. /dev/zero)
208 # from exhausting memory. Content is None when the file is too large or not UTF-8, but the mtime and inode
209 # still flow back so the caller can evict it as a stale, malformed lock and verify identity before breaking.
210 fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
211 try:
212 st, data = os.fstat(fd), os.read(fd, _MAX_LOCK_FILE_SIZE + 1)
213 finally:
214 os.close(fd)
215 if len(data) <= _MAX_LOCK_FILE_SIZE:
216 with suppress(UnicodeDecodeError):
217 return data.decode("utf-8"), st.st_mtime, st.st_ino
218 return None, st.st_mtime, st.st_ino
221def _parse_lock_holder(content: str | None) -> tuple[int, str, int | None] | None:
222 # A well-formed lock file is "<pid>\n<hostname>\n" with an optional "<creation_time>\n" third line on Windows.
223 # Anything else — wrong line count, a non-integer PID or creation time, empty or unreadable content — is
224 # unparsable; returning None lets the caller treat it as a malformed lock to self-heal rather than a holder.
225 if not content or len(lines := content.strip().splitlines()) not in {2, 3}:
226 return None
227 try:
228 pid = int(lines[0])
229 creation_time = int(lines[2]) if len(lines) == 3 else None # noqa: PLR2004
230 except ValueError:
231 return None
232 # A pid outside the valid range is a malformed lock, not a holder. Without this, a non-positive pid
233 # reaches os.kill() where 0 / -1 mean "the caller's own process group / every process" so a dead
234 # holder reads as alive and the lock is never reclaimed, while an oversized pid raises OverflowError
235 # (not OSError/ValueError) out of the self-heal path. _parse_marker_bytes already enforces this range.
236 if not 1 <= pid <= 2**31 - 1:
237 return None
238 return pid, lines[1], creation_time
241__all__ = [
242 "SoftFileLock",
243]