Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/filelock/_soft.py: 23%

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

140 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 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 

19 

20class SoftFileLock(BaseFileLock): 

21 """ 

22 Portable file lock based on file existence. 

23 

24 Unlike :class:`UnixFileLock <filelock.UnixFileLock>` and :class:`WindowsFileLock <filelock.WindowsFileLock>`, this 

25 lock does not use OS-level locking primitives. Instead, it creates the lock file with ``O_CREAT | O_EXCL`` and 

26 treats its existence as the lock indicator. This makes it work on any filesystem but leaves stale lock files behind 

27 if the process crashes without releasing the lock. 

28 

29 To mitigate stale locks, the lock file contains the PID and hostname of the holding process. On contention, if the 

30 holder is on the same host and its PID no longer exists, the stale lock is broken automatically. 

31 

32 """ 

33 

34 def _acquire(self) -> None: 

35 raise_on_not_writable_file(self.lock_file) 

36 ensure_directory_exists(self.lock_file) 

37 flags = ( 

38 os.O_WRONLY # open for writing only 

39 | os.O_CREAT 

40 | os.O_EXCL # together with above raise EEXIST if the file specified by filename exists 

41 | os.O_TRUNC # truncate the file to zero byte 

42 ) 

43 if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: 

44 flags |= o_nofollow 

45 try: 

46 file_handler = os.open(self.lock_file, flags, self._open_mode()) 

47 except OSError as exception: 

48 if not ( 

49 exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32") 

50 ): # pragma: win32 no cover 

51 raise 

52 self._try_break_stale_lock() 

53 else: 

54 self._write_lock_info(file_handler) 

55 self._context.lock_file_fd = file_handler 

56 

57 def _try_break_stale_lock(self) -> None: 

58 with suppress(OSError, ValueError): 

59 lock_path = Path(self.lock_file) 

60 stat_result = lock_path.stat() 

61 content = lock_path.read_text(encoding="utf-8") 

62 lines = content.strip().splitlines() 

63 

64 if len(lines) not in {2, 3}: 

65 if time.time() - stat_result.st_mtime >= _MALFORMED_LOCK_AGE_THRESHOLD: 

66 self._evict_lock_file() 

67 return 

68 

69 pid_str, hostname = lines[0], lines[1] 

70 creation_time_str = lines[2] if len(lines) == 3 else None # noqa: PLR2004 

71 

72 if hostname != socket.gethostname(): 

73 return 

74 

75 pid = int(pid_str) 

76 

77 if self._is_process_alive(pid): 

78 if sys.platform == "win32" and creation_time_str is not None: # pragma: win32 cover 

79 stored = int(creation_time_str) 

80 actual = self._get_process_creation_time(pid) 

81 if actual is not None and actual != stored: 

82 pass # PID recycled, fall through to evict 

83 else: 

84 return # same process or can't verify — don't evict 

85 else: 

86 return 

87 

88 self._evict_lock_file() 

89 

90 def _evict_lock_file(self) -> None: 

91 break_path = f"{self.lock_file}.break.{os.getpid()}" 

92 Path(self.lock_file).rename(break_path) 

93 Path(break_path).unlink() 

94 

95 @staticmethod 

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

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

98 import ctypes # noqa: PLC0415 

99 

100 kernel32 = ctypes.windll.kernel32 

101 handle = kernel32.OpenProcess(_WIN_SYNCHRONIZE, 0, pid) 

102 if handle: 

103 kernel32.CloseHandle(handle) 

104 return True 

105 return kernel32.GetLastError() != _WIN_ERROR_INVALID_PARAMETER 

106 try: 

107 os.kill(pid, 0) 

108 except OSError as exc: 

109 if exc.errno == ESRCH: 

110 return False 

111 if exc.errno == EPERM: 

112 return True 

113 raise 

114 return True 

115 

116 @staticmethod 

117 def _get_process_creation_time(pid: int) -> int | None: 

118 """Return the process creation FILETIME as an integer on Windows, ``None`` otherwise.""" 

119 if sys.platform != "win32": # pragma: win32 no cover 

120 return None 

121 import ctypes # pragma: win32 cover # noqa: PLC0415 

122 from ctypes import wintypes # noqa: PLC0415 

123 

124 kernel32 = ctypes.windll.kernel32 

125 handle = kernel32.OpenProcess(_WIN_PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) 

126 if not handle: 

127 return None 

128 try: 

129 creation = wintypes.FILETIME() 

130 exit_time = wintypes.FILETIME() 

131 kernel_time = wintypes.FILETIME() 

132 user_time = wintypes.FILETIME() 

133 if not kernel32.GetProcessTimes( 

134 handle, 

135 ctypes.byref(creation), 

136 ctypes.byref(exit_time), 

137 ctypes.byref(kernel_time), 

138 ctypes.byref(user_time), 

139 ): 

140 return None 

141 finally: 

142 kernel32.CloseHandle(handle) 

143 return (creation.dwHighDateTime << 32) | creation.dwLowDateTime 

144 

145 @staticmethod 

146 def _write_lock_info(fd: int) -> None: 

147 with suppress(OSError): 

148 info = f"{os.getpid()}\n{socket.gethostname()}\n" 

149 if sys.platform == "win32" and (ct := SoftFileLock._get_process_creation_time(os.getpid())) is not None: 

150 info += f"{ct}\n" 

151 os.write(fd, info.encode()) 

152 

153 @property 

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

155 """ 

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

157 

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

159 

160 """ 

161 try: 

162 content = Path(self.lock_file).read_text(encoding="utf-8") 

163 lines = content.strip().splitlines() 

164 if lines: 

165 return int(lines[0]) 

166 except (OSError, ValueError): 

167 pass 

168 return None 

169 

170 @property 

171 def is_lock_held_by_us(self) -> bool: 

172 """ 

173 Whether this lock is held by the current process. 

174 

175 :returns: ``True`` if the lock file exists and contains the current process's PID 

176 

177 """ 

178 return self.pid == os.getpid() 

179 

180 def break_lock(self) -> None: 

181 """Forcibly break the lock by removing the lock file, regardless of who holds it.""" 

182 with suppress(OSError): 

183 Path(self.lock_file).unlink() 

184 

185 def _release(self) -> None: 

186 assert self._context.lock_file_fd is not None # noqa: S101 

187 os.close(self._context.lock_file_fd) 

188 self._context.lock_file_fd = None 

189 if sys.platform == "win32": 

190 self._windows_unlink_with_retry() 

191 else: 

192 with suppress(OSError): 

193 Path(self.lock_file).unlink() 

194 

195 def _windows_unlink_with_retry(self) -> None: 

196 max_retries = 10 

197 retry_delay = 0.001 

198 for attempt in range(max_retries): 

199 # Windows doesn't immediately release file handles after close, causing EACCES/EPERM on unlink 

200 try: 

201 Path(self.lock_file).unlink() 

202 except OSError as exc: # noqa: PERF203 

203 if exc.errno not in {EACCES, EPERM}: 

204 return 

205 if attempt < max_retries - 1: 

206 time.sleep(retry_delay) 

207 retry_delay *= 2 

208 else: 

209 return 

210 

211 

212__all__ = [ 

213 "SoftFileLock", 

214]