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

153 statements  

1from __future__ import annotations 

2 

3import os 

4import socket 

5import sys 

6import time 

7from contextlib import suppress 

8from errno import EACCES, EEXIST, EPERM, ESRCH 

9from pathlib import Path 

10 

11from ._api import BaseFileLock 

12from ._util import break_lock_file, ensure_directory_exists, raise_on_not_writable_file 

13 

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 

19 

20 

21class SoftFileLock(BaseFileLock): 

22 """ 

23 Portable file lock based on file existence. 

24 

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. 

29 

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. 

32 

33 """ 

34 

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 

57 

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) 

62 

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 

70 

71 pid, hostname, creation_time = holder 

72 if hostname != socket.gethostname(): 

73 return 

74 

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. 

82 

83 break_lock_file(self.lock_file, mtime, ino) 

84 

85 @staticmethod 

86 def _is_process_alive(pid: int) -> bool: 

87 if sys.platform == "win32": # pragma: win32 cover 

88 import ctypes # noqa: PLC0415 

89 

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 

105 

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 

113 

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 

134 

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()) 

142 

143 @property 

144 def pid(self) -> int | None: 

145 """ 

146 The PID of the process holding this lock, read from the lock file. 

147 

148 :returns: the PID as an integer, or ``None`` if the lock file does not exist or cannot be parsed 

149 

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 

156 

157 @property 

158 def is_lock_held_by_us(self) -> bool: 

159 """ 

160 Whether this lock is held by the current process. 

161 

162 :returns: ``True`` if the lock file exists and names the current process's PID and hostname 

163 

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 

171 

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() 

176 

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() 

186 

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 

202 

203 

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 

219 

220 

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 

239 

240 

241__all__ = [ 

242 "SoftFileLock", 

243]