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

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

151 statements  

1from __future__ import annotations 

2 

3import os 

4import stat 

5import sys 

6import time 

7from contextlib import suppress 

8from errno import EACCES, EEXIST, EPERM 

9from pathlib import Path 

10from typing import Final 

11 

12from ._api import BaseFileLock, _raise_grouped_errors 

13from ._identity import host_name, owner_is_stale, process_start_token 

14from ._soft_protocol import STRICT_SOFT_SENTINEL_RECORD 

15from ._util import break_lock_file, ensure_directory_exists, raise_on_not_writable_file, write_all 

16 

17_MALFORMED_LOCK_AGE_THRESHOLD: Final[float] = 2.0 

18_MAX_LOCK_FILE_SIZE: Final[int] = 1024 

19_UNLINK_MAX_RETRIES: Final[int] = 10 

20_MARKER_WITH_START_TOKEN_LINE_COUNT: Final[int] = 3 

21 

22 

23class SoftFileLock(BaseFileLock): 

24 """ 

25 Cooperative file lock based on a shared existence marker. 

26 

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

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

29 treats its existence as the lock indicator. The filesystem must provide coherent exclusive creation and directory 

30 updates to each participating process. A crash can leave the marker behind. 

31 

32 The marker contains the holder's PID and hostname. A contender may remove it when it can no longer find a same-host 

33 process with that PID. A configured :attr:`~filelock.BaseFileLock.lifetime` also permits removal based on marker 

34 age, including while the holder remains alive. Age-based expiry can overlap protected operations and does not 

35 provide strict mutual exclusion. 

36 

37 """ 

38 

39 #: Existence locks reclaim by unlinking a pathname, so an age-based lease may break one; a native inode lock cannot. 

40 _lifetime_supported: bool = True 

41 

42 #: Age-based expiry preserves historical behavior but does not provide strict mutual exclusion. 

43 _lifetime_replacements: tuple[str, str] | None = ("StrictSoftFileLock", "SoftFileLease") 

44 

45 #: An existence lock unlinks its marker to release, so it cannot promise to keep the pathname. 

46 _preserve_lock_file_supported: bool = False 

47 

48 #: An existence lock keeps protocol state in its marker, so it cannot lend the descriptor to an on_acquired hook. 

49 _on_acquired_supported: bool = False 

50 

51 def _acquire(self) -> None: 

52 raise_on_not_writable_file(self.lock_file) 

53 ensure_directory_exists(self.lock_file) 

54 # O_CREAT | O_EXCL makes the create fail with EEXIST when the file already exists, so a successful open 

55 # means this process now holds the lock. 

56 flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC 

57 if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: # pragma: needs o-nofollow 

58 flags |= o_nofollow 

59 try: 

60 fd = os.open(self.lock_file, flags, self._open_mode()) 

61 except OSError as exception: 

62 if not ( 

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

64 ): # pragma: win32 no cover 

65 raise 

66 self._try_break_stale_lock() 

67 return 

68 self._mark_descriptor_pending(fd) 

69 self._publish_held_marker(fd) 

70 

71 def _publish_held_marker(self, fd: int) -> None: 

72 # Publish held state only once the record is fully on disk. On any failure, including cancellation, close the 

73 # descriptor and unlink the path only while it still names the file we opened, so a rollback never deletes a 

74 # successor's marker that replaced ours at the same path after our lease expired. 

75 identity: tuple[int, int] | None = None 

76 try: 

77 identity = _file_identity(os.fstat(fd)) 

78 self._write_lock_info(fd) 

79 except BaseException: 

80 self._mark_descriptor_released() 

81 os.close(fd) 

82 with suppress(OSError): 

83 if identity is not None and _file_identity(os.lstat(self.lock_file)) == identity: 

84 Path(self.lock_file).unlink() 

85 raise 

86 self._mark_descriptor_owned(fd, identity) 

87 

88 def _try_break_stale_lock(self) -> None: 

89 with suppress(OSError, ValueError): 

90 content, mtime, ino = _read_lock_file(self.lock_file) 

91 if content == STRICT_SOFT_SENTINEL_RECORD: # pragma: needs hard-link 

92 return 

93 holder = _parse_lock_holder(content) 

94 

95 if holder is None: 

96 # Unparsable: wrong line count, a non-integer PID or start token, empty, oversized or not UTF-8. 

97 # Self-heal only once the file is clearly not a half-written fresh lock (a peer between O_EXCL and 

98 # _write_lock_info), so the brief create-then-write window is never mistaken for a stale lock. 

99 if time.time() - mtime >= _MALFORMED_LOCK_AGE_THRESHOLD: 

100 break_lock_file(self.lock_file, mtime, ino) 

101 return 

102 

103 if owner_is_stale(*holder): 

104 break_lock_file(self.lock_file, mtime, ino) 

105 

106 @staticmethod 

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

108 # No suppression: a write failure must reach the acquisition rollback so it never publishes a half-written 

109 # marker as held state. The optional third line is this process's start token, absent when the platform 

110 # exposes no proven start time, in which case a reader falls back to PID-only liveness. 

111 info = f"{os.getpid()}\n{host_name()}\n" 

112 if (token := process_start_token(os.getpid())) is not None: 

113 info += f"{token}\n" 

114 write_all(fd, info.encode()) 

115 

116 @property 

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

118 """ 

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

120 

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

122 

123 """ 

124 with suppress(OSError, ValueError): 

125 holder = _parse_lock_holder(_read_lock_file(self.lock_file)[0]) 

126 if holder is not None: 

127 return holder[0] 

128 return None 

129 

130 @property 

131 def is_lock_held_by_us(self) -> bool: 

132 """ 

133 Whether this lock is held by the current process. 

134 

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

136 

137 """ 

138 with suppress(OSError, ValueError): 

139 holder = _parse_lock_holder(_read_lock_file(self.lock_file)[0]) 

140 if holder is not None: 

141 pid, hostname, _ = holder 

142 return pid == os.getpid() and hostname == host_name() 

143 return False 

144 

145 def break_lock(self) -> None: 

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

147 with suppress(OSError): 

148 Path(self.lock_file).unlink() 

149 

150 def _release(self) -> None: 

151 fd = self._context.lock_file_fd 

152 assert fd is not None # ruff:ignore[assert] # _release runs only while held, so the descriptor is set 

153 # Capture the held file's identity before closing so cleanup can refuse to unlink a successor's marker. A 

154 # supported lifetime lease lets a peer break our expired marker and create its own at this path before we 

155 # release; unlinking by path alone would then delete the successor's lock. 

156 identity: tuple[int, int] | None = None 

157 with suppress(OSError): 

158 identity = _file_identity(os.fstat(fd)) 

159 # A failed close may already have released and recycled the descriptor number. Relinquish it before the one 

160 # close attempt so no later release can close an unrelated descriptor that reused the same integer. 

161 self._mark_descriptor_released() 

162 try: 

163 self._close_released_fd(fd, default_suppresses=False) 

164 # Marker cleanup must also run for control-flow exceptions, and both failures must remain observable. 

165 except BaseException as close_error: 

166 try: 

167 self._unlink_held_marker(identity) 

168 except BaseException as cleanup_error: # ruff:ignore[blind-except] # preserve control-flow cleanup failures 

169 _raise_grouped_errors( 

170 "lock descriptor close and marker cleanup both failed", 

171 close_error, 

172 cleanup_error, 

173 ) 

174 raise 

175 self._unlink_held_marker(identity) 

176 

177 def _unlink_held_marker(self, identity: tuple[int, int] | None) -> None: 

178 if identity is None: 

179 return 

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

181 self._windows_unlink_if_ours(identity) 

182 else: # pragma: win32 no cover 

183 with suppress(OSError): 

184 if _file_identity(os.lstat(self.lock_file)) == identity: 

185 Path(self.lock_file).unlink() 

186 

187 def _windows_unlink_if_ours(self, identity: tuple[int, int]) -> None: # pragma: win32 cover 

188 retry_delay = 0.001 

189 for attempt in range(_UNLINK_MAX_RETRIES): 

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

191 # identity each attempt: a failed unlink leaves a window for a successor to replace the marker at this path. 

192 try: 

193 if _file_identity(os.lstat(self.lock_file)) != identity: 

194 return 

195 Path(self.lock_file).unlink() 

196 except OSError as exc: # ruff:ignore[try-except-in-loop] # each attempt's errno drives the retry choice 

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

198 return 

199 if attempt < _UNLINK_MAX_RETRIES - 1: 

200 time.sleep(retry_delay) 

201 retry_delay *= 2 

202 else: 

203 return 

204 

205 

206def _file_identity(st: os.stat_result) -> tuple[int, int]: 

207 # (st_dev, st_ino) names the concrete inode behind a path, so a marker recreated at the same pathname after an 

208 # expired lease reads as a different file. CPython populates both on Windows from the volume serial and file index. 

209 return st.st_dev, st.st_ino 

210 

211 

212def _read_lock_file(path: str) -> tuple[str | None, float, int]: 

213 # A legitimate lock file is always a regular file. Classify the path with lstat first, so any other node (symlink, 

214 # FIFO, socket, device) is reported as a malformed lock the caller can evict, without an os.open that would follow 

215 # a symlink, stall on a FIFO, or fail on a socket and leave acquisition wedged. The mtime and inode still flow back 

216 # for the identity-checked stale break. lstat, not stat, so a hostile symlink is never followed onto its target. 

217 st = os.lstat(path) 

218 if not stat.S_ISREG(st.st_mode): # pragma: needs fifo 

219 return None, st.st_mtime, st.st_ino 

220 # Re-check on the opened handle: O_NOFOLLOW refuses a symlink swapped in after the lstat, O_NONBLOCK stops a FIFO 

221 # swapped in from stalling the open, and the fstat catches any other non-regular replacement race before we read. 

222 # The capped read stops a huge regular file (e.g. one filled from /dev/zero) from exhausting memory. 

223 fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) 

224 try: 

225 st = os.fstat(fd) 

226 if not stat.S_ISREG(st.st_mode): # pragma: no cover # only a non-regular node swapped in after the lstat 

227 return None, st.st_mtime, st.st_ino 

228 data = os.read(fd, _MAX_LOCK_FILE_SIZE + 1) 

229 finally: 

230 os.close(fd) 

231 if len(data) <= _MAX_LOCK_FILE_SIZE: 

232 with suppress(UnicodeDecodeError): 

233 return data.decode("utf-8"), st.st_mtime, st.st_ino 

234 return None, st.st_mtime, st.st_ino 

235 

236 

237def _parse_lock_holder(content: str | None) -> tuple[int, str, int | None] | None: 

238 # A well-formed lock file is "<pid>\n<hostname>\n" with an optional "<start_token>\n" third line naming the 

239 # holder's process start instant (a filelock 3.29 marker wrote this only on Windows; every platform writes it now). 

240 # Anything else (wrong line count, a non-integer PID or start token, empty or unreadable content) is unparsable; 

241 # returning None lets the caller treat it as a malformed lock to self-heal rather than a holder. 

242 if not content or len(lines := content.strip().splitlines()) not in {2, 3}: 

243 return None 

244 try: 

245 pid = int(lines[0]) 

246 start_token = int(lines[2]) if len(lines) == _MARKER_WITH_START_TOKEN_LINE_COUNT else None 

247 except ValueError: 

248 return None 

249 # A pid outside the valid range is a malformed lock, not a holder. Without this, a non-positive pid 

250 # reaches os.kill() where 0 / -1 mean "the caller's own process group / every process" so a dead 

251 # holder reads as alive and the lock is never reclaimed, while an oversized pid raises OverflowError 

252 # (not OSError/ValueError) out of the self-heal path. _parse_marker_bytes already enforces this range. 

253 if not 1 <= pid <= 2**31 - 1: 

254 return None 

255 return pid, lines[1], start_token 

256 

257 

258__all__ = [ 

259 "SoftFileLock", 

260]