Coverage for cuda/core/utils/_program_cache/_file_stream.py: 81.21%

282 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-05 01:11 +0000

1# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5"""On-disk bytes-in / bytes-out program cache. 

6 

7Atomic writes via :func:`os.replace`. Concurrent readers see either the 

8old entry or the new one, never a partial file. Each entry is the raw 

9compiled binary so files are directly consumable by external NVIDIA 

10tools (``cuobjdump``, ``nvdisasm``, ``cuda-gdb``). 

11""" 

12 

13from __future__ import annotations 

14 

15import contextlib 

16import errno 

17import hashlib 

18import os 

19import tempfile 

20import threading 

21import time 

22from collections.abc import Callable, Iterable 

23from pathlib import Path 

24from typing import Any 

25 

26from cuda.core._module import ObjectCode 

27from cuda.core.utils._cache_dir import _default_cache_dir as _user_cache_dir 

28 

29from ._abc import ProgramCacheResource, _as_key_bytes, _extract_bytes 

30 

31_ENTRIES_SUBDIR = "entries" 

32_TMP_SUBDIR = "tmp" 

33# Temp files older than this are assumed to belong to a crashed writer and 

34# are eligible for cleanup. Picked large enough that no real ``os.replace`` 

35# write should still be in flight (writes are bounded by mkstemp + write + 

36# fsync + replace, all fast on healthy disks). 

37_TMP_STALE_AGE_SECONDS = 3600 

38 

39 

40_SHARING_VIOLATION_WINERRORS = (5, 32, 33) # ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION 

41_REPLACE_RETRY_DELAYS = (0.0, 0.005, 0.010, 0.020, 0.050, 0.100) # ~185ms budget 

42 

43 

44# Exposed as a module-level flag so tests can toggle it without monkeypatching 

45# ``os.name`` itself (pathlib reads ``os.name`` at instantiation time). 

46_IS_WINDOWS = os.name == "nt" 

47 

48 

49def _stat_key(st: os.stat_result) -> tuple[int, int, int]: 

50 """Stat fingerprint used by every stat-guarded path. 

51 

52 ``(st_ino, st_size, st_mtime_ns)`` is the smallest triple that 

53 distinguishes "same file" from "file replaced under us": ``st_ino`` 

54 catches replacement, ``st_size`` and ``st_mtime_ns`` catch a write 

55 that happens to land on the same inode (e.g. truncate-and-write in 

56 place). Centralised so all four readers compare the same fields. 

57 """ 

58 return (st.st_ino, st.st_size, st.st_mtime_ns) 1sABCDbxytzhfEFqG3HpIJacOieKjrnulgmdL67vw

59 

60 

61def _default_cache_dir() -> Path: 

62 """Default location for the file-stream cache: the ``program-cache`` leaf under the shared 

63 ``cuda-python`` user-cache root (see :func:`cuda.core.utils._cache_dir._default_cache_dir`). 

64 """ 

65 return _user_cache_dir() / "program-cache" 1=

66 

67 

68def _with_sharing_retry( 

69 op: Callable[..., Any], *args: Any, on_exhausted: Callable[..., Any] | None = None, **kwargs: Any 

70) -> Any: 

71 """Run ``op(*args, **kwargs)`` retrying transient Windows sharing 

72 violations under the bounded ``_REPLACE_RETRY_DELAYS`` budget. 

73 

74 On Windows, ``os.replace``/``read_bytes``/``unlink`` can surface 

75 winerror 5/32/33 (or bare EACCES via ``_is_windows_sharing_violation``) 

76 while another process briefly holds the file open without share-delete 

77 rights. The retry hides that contention. Other ``PermissionError``s 

78 (real ACLs, unexpected winerror) propagate immediately. 

79 

80 Successful returns and any non-``PermissionError`` exceptions 

81 (including ``FileNotFoundError``) bubble up unchanged. After the 

82 budget is exhausted, the helper either calls ``on_exhausted(last_exc)`` 

83 if provided, or re-raises the last sharing-violation exception. 

84 """ 

85 last_exc: PermissionError | None = None 1sABCDbXxytzS$hfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#67vw

86 for delay in _REPLACE_RETRY_DELAYS: 1sABCDbXxytzS$hfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#67vw

87 if delay: 1sABCDbXxytzS$hfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#67vw

88 time.sleep(delay) 1PQRpMe

89 try: 1sABCDbXxytzS$hfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#67vw

90 return op(*args, **kwargs) 1sABCDbXxytzS$hfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#67vw

91 except PermissionError as exc: 1sbXS$hf8PQ01RpacWMernumvw

92 if not _is_windows_sharing_violation(exc): 1hf8PQ01RpWMerm

93 raise 1hf801Wr

94 last_exc = exc 1PQRpMem

95 if on_exhausted is not None: 1PQRem

96 return on_exhausted(last_exc) 1PQRm

97 if last_exc is None: 1e

98 raise RuntimeError("internal cuda.core error, please report: sharing retry ran no attempts") 

99 raise last_exc 1e

100 

101 

102def _replace_with_sharing_retry(tmp_path: Path, target: Path) -> bool: 

103 """Atomic rename with Windows-specific retry on sharing/lock violations. 

104 

105 Returns True on success. Returns False only after the retry budget is 

106 exhausted on Windows with a genuine sharing violation -- the caller then 

107 treats the cache write as dropped. Any other ``PermissionError`` (ACLs, 

108 read-only dir, unexpected winerror, or any POSIX failure) propagates. 

109 

110 ``ERROR_ACCESS_DENIED`` (winerror 5) is treated as a sharing violation 

111 because Windows surfaces it when a file is held open without 

112 ``FILE_SHARE_WRITE`` (Python's default for ``open(p, "wb")``) or while 

113 a previous unlink is in ``PENDING_DELETE`` -- both are transient. 

114 """ 

115 

116 def _do_replace() -> bool: 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

117 os.replace(tmp_path, target) 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

118 return True 1sABCDbxytzShfEF4qG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

119 

120 return bool(_with_sharing_retry(_do_replace, on_exhausted=lambda _exc: False)) 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

121 

122 

123def _stat_and_read_with_sharing_retry(path: Path) -> tuple[os.stat_result, bytes]: 

124 """Snapshot stat and read bytes, retrying briefly on Windows transient 

125 sharing-violation ``PermissionError``. 

126 

127 Reads race the rewriter's ``os.replace``: on Windows, the destination 

128 can be momentarily inaccessible (winerror 5/32/33) while the rename 

129 completes. Mirroring ``_replace_with_sharing_retry``'s budget keeps 

130 transient contention from being mistaken for a real read failure. 

131 

132 Raises ``FileNotFoundError`` on miss or after exhausting the Windows 

133 sharing-retry budget. Non-Windows ``PermissionError`` propagates. 

134 

135 On Windows, EACCES (errno 13) is treated as transient too: ``io.open`` 

136 sometimes surfaces a pending-delete or share-mode mismatch as bare 

137 EACCES with no ``winerror`` attribute, indistinguishable here from 

138 a true sharing violation. Real ACL problems on a path the cache owns 

139 would surface consistently; the bounded retry budget keeps the cost 

140 of treating them as transient negligible. 

141 """ 

142 

143 def _do_stat_and_read() -> tuple[os.stat_result, bytes]: 1sABCDbXS$EFqPQRGHpIJacMKngmTVdL67vw

144 return path.stat(), path.read_bytes() 1sABCDbXS$EFqPQRGHpIJacMKngmTVdL67vw

145 

146 def _exhausted(last_exc: PermissionError) -> None: 1sABCDbXS$EFqPQRGHpIJacMKngmTVdL67vw

147 raise FileNotFoundError(path) from last_exc 1m

148 

149 return _with_sharing_retry(_do_stat_and_read, on_exhausted=_exhausted) # type: ignore[no-any-return] 1sABCDbXS$EFqPQRGHpIJacMKngmTVdL67vw

150 

151 

152_UTIME_SUPPORTS_FD = os.utime in os.supports_fd 

153 

154 

155def _touch_atime(path: Path, st_before: os.stat_result) -> None: 

156 """Bump ``path``'s atime to "now", preserving its mtime, iff the 

157 file's stat still matches ``st_before``. 

158 

159 Eviction sorts by ``st_atime`` so reads must reliably refresh atime 

160 regardless of OS or filesystem default behavior: 

161 

162 * Linux ``relatime`` (default) only updates atime when the existing 

163 atime is older than mtime, which would skew LRU once an entry has 

164 been read once. 

165 * NTFS on Windows Vista+ disables atime updates by default 

166 (``NtfsDisableLastAccessUpdate``) and most modern installations 

167 keep that off, so a bare read never bumps atime. 

168 * ``noatime``-mounted filesystems disable updates entirely. 

169 

170 Calling ``os.utime`` with explicit times bypasses all of the above 

171 and writes atime directly. The stat-guard is critical: if another 

172 process ``os.replace``-d a fresh entry into ``path`` between the 

173 read and this touch, blindly applying ``st_before.st_mtime_ns`` 

174 would roll the new entry's mtime back to the old value and confuse 

175 the eviction stat-guard (which checks ``(ino, size, mtime_ns)``) 

176 into deleting a freshly-committed file. 

177 

178 Where ``os.utime`` supports file descriptors (Linux, macOS), the 

179 fstat-then-utime pair runs against the same open fd: even if another 

180 writer replaces the path between our ``os.open`` and the ``fstat``, 

181 the fd still refers to the file we opened, so the comparison and the 

182 utime both target the same inode. This closes the residual TOCTOU 

183 window that a path-based stat + path-based utime would have. 

184 

185 On Windows, ``os.utime`` is path-only; the fallback re-stats the 

186 path and accepts a small TOCTOU window between the second stat and 

187 the utime. That window is microseconds and the worst-case outcome 

188 is the racing writer's mtime being rolled back by a few hundred 

189 nanoseconds -- the eviction stat-guard would then refuse to evict 

190 the slightly-stale entry, costing one cache miss (recompile) but 

191 not a corrupt eviction. 

192 

193 Best-effort: any ``OSError`` (read-only mount, restrictive ACLs, 

194 ...) is swallowed -- size enforcement still bounds the cache, but 

195 eviction degrades toward FIFO. 

196 """ 

197 new_atime_ns = time.time_ns() 1sABCDbEFqGHpIJacOKgm9TVdL67vw

198 if _UTIME_SUPPORTS_FD: 1sABCDbEFqGHpIJacOKgm9TVdL67vw

199 try: 1sABCDbEFqGHpIJacOKgmTVdL67vw

200 fd = os.open(path, os.O_RDONLY) 1sABCDbEFqGHpIJacOKgmTVdL67vw

201 except OSError: 1V

202 return 1V

203 try: 1sABCDbEFqGHpIJacOKgmTdL67vw

204 try: 1sABCDbEFqGHpIJacOKgmTdL67vw

205 st_now = os.fstat(fd) 1sABCDbEFqGHpIJacOKgmTdL67vw

206 except OSError: 1T

207 return 1T

208 if _stat_key(st_now) != _stat_key(st_before): 1sABCDbEFqGHpIJacOKgmdL67vw

209 return 1O

210 with contextlib.suppress(OSError): 1sABCDbEFqGHpIJacOKgmdL67vw

211 os.utime(fd, ns=(new_atime_ns, st_before.st_mtime_ns)) 1sABCDbEFqGHpIJacOKgmdL67vw

212 finally: 

213 os.close(fd) 1sABCDbEFqGHpIJacOKgmTdL67vw

214 return 1sABCDbEFqGHpIJacOKgmdL67vw

215 

216 # Path-based fallback (Windows). Best-effort -- residual TOCTOU window 

217 # documented above. 

218 try: 1sABCDbEFqGHpIJacOKgm9dL67vw

219 st_now = path.stat() 1sABCDbEFqGHpIJacOKgm9dL67vw

220 except OSError: 19

221 return 19

222 if _stat_key(st_now) != _stat_key(st_before): 1sABCDbEFqGHpIJacOKgmdL67vw

223 return 1O

224 with contextlib.suppress(OSError): 1sABCDbEFqGHpIJacOKgmdL67vw

225 os.utime(path, ns=(new_atime_ns, st_before.st_mtime_ns)) 1sABCDbEFqGHpIJacOKgmdL67vw

226 

227 

228def _is_windows_sharing_violation(exc: BaseException) -> bool: 

229 """Return True if ``exc`` is a Windows sharing/lock violation that 

230 :func:`_unlink_with_sharing_retry` would have retried. 

231 

232 Used by best-effort callers to filter out the exhausted-retry case 

233 while letting other ``PermissionError`` instances (POSIX ACL 

234 issues, Windows non-sharing winerrors) propagate -- those are real 

235 configuration problems, not transient contention. 

236 

237 The ``EACCES`` fallback only fires when ``winerror`` is absent: a 

238 bare ``EACCES`` (no winerror attached) is the way ``io.open`` 

239 surfaces a pending-delete or share-mode mismatch on Windows. When 

240 ``winerror`` IS set but is NOT in the sharing set, the OS told us 

241 exactly what failed and it isn't a sharing violation -- treating it 

242 as transient would silently swallow real errors like a corrupt 

243 ACL. 

244 """ 

245 if not _IS_WINDOWS: 1hf8PQ01RpWMerm/

246 return False 1h8W/

247 if not isinstance(exc, PermissionError): 1fPQ01RpMerm/

248 return False 

249 winerror = getattr(exc, "winerror", None) 1fPQ01RpMerm/

250 if winerror in _SHARING_VIOLATION_WINERRORS: 1fPQ01RpMerm/

251 return True 1PQRpMem/

252 return winerror is None and exc.errno == errno.EACCES 1f01r/

253 

254 

255def _unlink_with_sharing_retry(path: Path) -> None: 

256 """Unlink with Windows-specific retry on sharing/lock violations. 

257 

258 On Windows, ``Path.unlink`` raises ``PermissionError`` (winerror 5, 

259 32, or 33; sometimes bare ``EACCES``) when another process holds 

260 the file open without ``FILE_SHARE_DELETE``. Python's default 

261 ``open(p, "rb")`` does not pass that flag, so a reader from another 

262 process briefly blocks our unlink while it reads. Retry with the 

263 same backoff budget as :func:`_replace_with_sharing_retry` so 

264 transient contention is not turned into a propagated error. 

265 

266 Raises ``FileNotFoundError`` if the file is absent; the last 

267 ``PermissionError`` if the Windows retry budget is exhausted; and 

268 propagates any non-sharing ``PermissionError`` (or any non-Windows 

269 ``PermissionError``) immediately. Best-effort callers should use 

270 :func:`_is_windows_sharing_violation` to filter the exhausted-retry 

271 case and re-raise any other ``PermissionError``. 

272 """ 

273 _with_sharing_retry(path.unlink) 1bxytzShf3ackiWMejrnul

274 

275 

276def _prune_if_stat_unchanged(path: Path, st_before: os.stat_result) -> None: 

277 """Unlink ``path`` iff its stat still matches ``st_before``. 

278 

279 Guards against a cross-process race: a reader that sees a corrupt 

280 record can have it atomically replaced (via ``os.replace``) by a 

281 writer before the reader decides to prune. Comparing 

282 ``(ino, size, mtime_ns)`` before and after rules out that case -- 

283 any mismatch means someone else wrote a new file and we must not 

284 delete their work. The residual TOCTOU window between stat and 

285 unlink is narrow; worst case, a very-recently-written entry is 

286 removed and the next read recompiles. 

287 

288 Best-effort: a Windows sharing violation that survives the retry 

289 budget leaves the file in place. The caller is in an eviction or 

290 cleanup pass, so re-trying on the next pass is the right outcome. 

291 """ 

292 try: 1xytz3rnNul

293 st_now = path.stat() 1xytz3rnNul

294 except FileNotFoundError: 1N

295 return 1N

296 if _stat_key(st_before) != _stat_key(st_now): 1xytz3rnul

297 return 1y3

298 try: 1xytz3rnul

299 _unlink_with_sharing_retry(path) 1xytz3rnul

300 except FileNotFoundError: 1ru

301 pass 1u

302 except PermissionError as exc: 1r

303 # Swallow only the exhausted-Windows-sharing case. POSIX ACL 

304 # errors and Windows non-sharing winerrors are real configuration 

305 # problems and must surface, not be silently lost during a prune. 

306 if not _is_windows_sharing_violation(exc): 1r

307 raise 1r

308 

309 

310class FileStreamProgramCache(ProgramCacheResource): 

311 """Persistent program cache backed by a directory of atomic files. 

312 

313 Designed for multi-process use: writes stage a temporary file and then 

314 :func:`os.replace` it into place, so concurrent readers never observe a 

315 partially-written entry. Each entry on disk is the raw compiled binary 

316 -- cubin / PTX / LTO-IR -- with no header, framing, or pickle wrapper, 

317 so the files are directly consumable by external NVIDIA tools 

318 (``cuobjdump``, ``nvdisasm``, ``cuda-gdb``). 

319 

320 Eviction is by least-recently-*read* time: every successful read bumps 

321 the entry's ``atime``, and the size enforcer evicts oldest atime 

322 first. 

323 

324 .. note:: **Best-effort writes.** 

325 

326 On Windows, ``os.replace`` raises ``PermissionError`` (winerror 

327 32 / 33) when another process holds the target file open. This 

328 backend retries with bounded backoff (~185 ms) and, if still 

329 failing, drops the cache write silently and returns success-shaped 

330 control flow. The next call will see no entry and recompile. POSIX 

331 and other ``PermissionError`` codes propagate. 

332 

333 .. note:: **Atomic for readers, not crash-durable.** 

334 

335 Each entry's temp file is ``fsync``-ed before ``os.replace``, but 

336 the containing directory is **not** ``fsync``-ed. A host crash 

337 between write and the next directory commit may lose recently 

338 added entries; surviving entries remain consistent. 

339 

340 .. note:: **Cross-version sharing.** 

341 

342 The cache is safe to share across ``cuda.core`` patch releases: 

343 every key produced by :func:`make_program_cache_key` encodes the 

344 relevant backend/compiler/runtime fingerprints for its 

345 compilation path (NVRTC entries pin the NVRTC version, NVVM 

346 entries pin the libNVVM library and IR versions, PTX/linker 

347 entries pin the chosen linker backend and its version -- and, 

348 when the cuLink/driver backend is selected, the driver version 

349 too; nvJitLink-backed PTX entries are deliberately 

350 driver-version independent). Bumping ``_KEY_SCHEMA_VERSION`` 

351 (mixed into the digest by ``make_program_cache_key``) produces 

352 new keys that don't collide with old entries: post-bump 

353 lookups miss the old on-disk paths, and the orphaned files 

354 are reaped on the next size-cap eviction pass. Entries are 

355 stored verbatim as the compiled binary, so cross-patch sharing 

356 only requires that the compiler-pinning surface above stays 

357 stable -- there is no Python-pickle compatibility involved. 

358 

359 Parameters 

360 ---------- 

361 path: 

362 Directory that owns the cache. Created if missing. If omitted, 

363 the OS-conventional user cache directory is used: 

364 ``$XDG_CACHE_HOME/cuda-python/program-cache`` (Linux, defaulting 

365 to ``~/.cache/cuda-python/program-cache``) or 

366 ``%LOCALAPPDATA%\\cuda-python\\program-cache`` (Windows). 

367 max_size_bytes: 

368 Optional soft cap on total on-disk size. Enforced opportunistically 

369 on writes; concurrent writers may briefly exceed it. Eviction is by 

370 least-recently-read time (oldest ``st_atime`` first). 

371 """ 

372 

373 def __init__( 

374 self, 

375 path: str | os.PathLike[str] | None = None, 

376 *, 

377 max_size_bytes: int | None = None, 

378 ) -> None: 

379 if max_size_bytes is not None and max_size_bytes <= 0: 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,:;pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

380 raise ValueError("max_size_bytes must be positive or None (0 would evict every write)") 1:;

381 self._root = Path(path) if path is not None else _default_cache_dir() 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

382 self._entries = self._root / _ENTRIES_SUBDIR 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

383 self._tmp = self._root / _TMP_SUBDIR 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

384 self._max_size_bytes = max_size_bytes 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

385 # Permissions (see PR #2399): 

386 # root/ and entries/ use default permissions so a shared cache (e.g. one 

387 # a group shares on a cluster) keeps working. The cached files themselves 

388 # are still private: each is written to tmp/ as owner-only and moved into 

389 # entries/, which keeps its permissions. tmp/ is made owner-only so no one 

390 # can read or swap a file while it's being written. We don't chmod, so an 

391 # existing directory is left as-is. 

392 # Trade-off: if a group deliberately shares a writable entries/, a member 

393 # could replace a cached file. Blocking that needs a check at load time, 

394 # not just permissions, and is out of scope here. 

395 self._root.mkdir(parents=True, exist_ok=True) 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

396 self._entries.mkdir(exist_ok=True) 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

397 self._tmp.mkdir(exist_ok=True, mode=0o700) 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

398 # Opportunistic startup sweep of orphaned temp files left by any 

399 # crashed writers. Age-based so concurrent in-flight writes from 

400 # other processes are preserved. 

401 self._sweep_stale_tmp_files() 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

402 # Incremental size tracker. Without it every ``__setitem__`` would 

403 # walk ``entries/`` + ``tmp/`` to compute the total -- O(n) per 

404 # write. With it: writes update the tracker by the net delta in O(1) 

405 # and only walk on eviction (which already needs the scan to sort 

406 # entries by atime). The tracker is seeded by one full scan at open 

407 # time and refreshed on every eviction pass; cross-process drift 

408 # (other writers/deleters) self-corrects the next time eviction 

409 # fires. The lock guards mutations so multi-threaded writers in 

410 # the same process don't interleave the read-modify-write on the 

411 # int. Skipped entirely when ``max_size_bytes is None`` -- without 

412 # a cap the tracker is dead weight. 

413 self._size_lock = threading.Lock() 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

414 self._tracked_size_bytes = self._compute_total_size() if max_size_bytes is not None else 0 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

415 

416 # -- key-to-path helpers ------------------------------------------------- 

417 

418 def _path_for_key(self, key: object) -> Path: 

419 k = _as_key_bytes(key) 1sABCDbXxytzS$hfEF4q8PQ01RG3HpIJaocOki5WMeK'UjrnNul%Y!2Zgm9TVdL#67vw

420 # Hash the key to a fixed-length identifier so arbitrary-length user 

421 # keys never exceed per-component filename limits (typically 255 on 

422 # ext4 / NTFS). 

423 # 

424 # FIPS: must use a FIPS-approved hash algorithm. FIPS-enforcing 

425 # systems can disable non-approved hashlib algorithms (for example 

426 # blake2b) at the OpenSSL level. See #2043. 

427 # 

428 # With a 256-bit SHA-256 digest, the cache relies on collision 

429 # resistance for key uniqueness -- two distinct keys hashing to the 

430 # same path is astronomically unlikely (~2^128 practical collision 

431 # work). 

432 digest = hashlib.sha256(k, usedforsecurity=False).hexdigest() 1sABCDbXxytzS$hfEF4q8PQ01RG3HpIJaocOki5WMeK'UjrnNul%Y!2Zgm9TVdL#67vw

433 return self._entries / digest[:2] / digest[2:] 1sABCDbXxytzS$hfEF4q8PQ01RG3HpIJaocOki5WMeK'UjrnNul%Y!2Zgm9TVdL#67vw

434 

435 # -- mapping API --------------------------------------------------------- 

436 

437 def __getitem__(self, key: object) -> bytes: 

438 path = self._path_for_key(key) 1sABCDbXS$EFqPQRGHpIJacMKngmTVdL67vw

439 try: 1sABCDbXS$EFqPQRGHpIJacMKngmTVdL67vw

440 # The helper retries on Windows transient sharing-violation 

441 # PermissionErrors so a racing rewriter doesn't turn a hit 

442 # into a spurious propagated error. 

443 st, data = _stat_and_read_with_sharing_retry(path) 1sABCDbXS$EFqPQRGHpIJacMKngmTVdL67vw

444 except FileNotFoundError: 1sbXS$PQRacMnmvw

445 raise KeyError(key) from None 1sbXS$PQRacMnmvw

446 # Bump atime to "now" so eviction (which sorts by st_atime) treats 

447 # this read as the entry's most recent use. Best-effort: filesystems 

448 # mounted ``noatime`` or with restrictive ACLs may refuse, in which 

449 # case the cap still bounds size but eviction degrades toward FIFO 

450 # rather than true LRU. 

451 _touch_atime(path, st) 1sABCDbEFqGHpIJacKgmTVdL67vw

452 return data 1sABCDbEFqGHpIJacKgmTVdL67vw

453 

454 def __setitem__(self, key: object, value: bytes | bytearray | memoryview | ObjectCode) -> None: 

455 data = _extract_bytes(value) 1sABCDbXxytzShfEF4q+8PQ01RG3H,pIJaocOki5WMeK'UjrnNulY!2Zgm9TVdL#vw

456 target = self._path_for_key(key) 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeK'UjrnNulY!2Zgm9TVdL#vw

457 target.parent.mkdir(parents=True, exist_ok=True) 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeK'UjrnNulY!2Zgm9TVdL#vw

458 # Re-create ``tmp/`` if something deleted it after ``__init__`` 

459 # (operators clearing the cache by hand, ``rm -rf cache_dir/tmp``, 

460 # another process's overzealous wipe). Cheap and idempotent; 

461 # without it, every subsequent write would crash with 

462 # FileNotFoundError even though we could trivially recover. 

463 self._tmp.mkdir(parents=True, exist_ok=True) 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeK'UjrnNulY!2Zgm9TVdL#vw

464 

465 # Stat the existing entry (if any) BEFORE the replace so we can 

466 # update the tracker by the net delta. A racing writer that lands 

467 # an ``os.replace`` between this stat and our own makes ``old_size`` 

468 # slightly off; the next ``_enforce_size_cap`` reconciles by 

469 # re-scanning. Skipped when ``max_size_bytes is None`` (no tracker). 

470 old_size = 0 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeK'UjrnNulY!2Zgm9TVdL#vw

471 if self._max_size_bytes is not None: 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeK'UjrnNulY!2Zgm9TVdL#vw

472 try: 1bhfaockieUjlgd

473 old_size = target.stat().st_size 1bhfaockieUjlgd

474 except FileNotFoundError: 1bhfaockieUjlgd

475 old_size = 0 1bhfaockieUjlgd

476 

477 fd, tmp_name = tempfile.mkstemp(prefix="entry-", dir=self._tmp) 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeK'UjrnNulY!2Zgm9TVdL#vw

478 tmp_path = Path(tmp_name) 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

479 try: 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

480 with os.fdopen(fd, "wb") as fh: 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

481 fh.write(data) 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

482 fh.flush() 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

483 os.fsync(fh.fileno()) 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

484 # Retry os.replace under Windows sharing/lock violations; only 

485 # give up (and drop the cache write) after a bounded backoff, so 

486 # transient contention is not turned into a silent miss. 

487 # Non-sharing PermissionErrors and all POSIX PermissionErrors 

488 # propagate immediately (real config problem). 

489 if not _replace_with_sharing_retry(tmp_path, target): 1sABCDbXxytzShfEF4q8PQ01RG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

490 with contextlib.suppress(FileNotFoundError): 1PQR

491 tmp_path.unlink() 1PQR

492 return 1PQR

493 except BaseException: 1X801

494 with contextlib.suppress(FileNotFoundError): 1X801

495 tmp_path.unlink() 1X801

496 raise 1X801

497 

498 if self._max_size_bytes is None: 1sABCDbxytzShfEF4qG3HpIJaocOki5WMeKUjrnNulY!2Zgm9TVdL#vw

499 return 1sABCDxytzSEF4qG3HpIJO5WMKrnNuY!2Zgm9TVL#vw

500 

501 # O(1) tracker update. Only run the scan-heavy ``_enforce_size_cap`` 

502 # when this write actually pushes the running total above the cap. 

503 new_size = len(data) 1bhfaockieUjlgd

504 with self._size_lock: 1bhfaockieUjlgd

505 self._tracked_size_bytes += new_size - old_size 1bhfaockieUjlgd

506 over_cap = self._tracked_size_bytes > self._max_size_bytes 1bhfaockieUjlgd

507 if over_cap: 1bhfaockieUjlgd

508 self._enforce_size_cap() 1bhfaociejd

509 

510 def __delitem__(self, key: object) -> None: 

511 path = self._path_for_key(key) 1SkWM%

512 # Stat before unlink so we can decrement the tracker by the actual 

513 # on-disk size. Best-effort: if the file vanishes between stat and 

514 # unlink (concurrent eviction), we treat the delete as a miss -- 

515 # matching the behaviour callers expect (KeyError) and leaving the 

516 # tracker untouched (the racing eviction already accounted for it). 

517 size = 0 1SkWM%

518 if self._max_size_bytes is not None: 1SkWM%

519 try: 1k%

520 size = path.stat().st_size 1k%

521 except FileNotFoundError: 1%

522 raise KeyError(key) from None 1%

523 try: 1SkWM

524 _unlink_with_sharing_retry(path) 1SkWM

525 except FileNotFoundError: 1SW

526 raise KeyError(key) from None 1S

527 if self._max_size_bytes is not None: 1SkM

528 with self._size_lock: 1k

529 # Clamp at zero. A racing ``_enforce_size_cap`` can re-seed the 

530 # tracker between our stat and our subtract; if its scan ran 

531 # AFTER we unlinked, its reseed value didn't include ``size``, 

532 # so subtracting ``size`` again here would undercount reality 

533 # by ``size``. Repeated under contention, an unclamped subtract 

534 # walks the tracker negative -- and once negative, the 

535 # ``tracker > cap`` check that gates ``_enforce_size_cap`` 

536 # never fires, so eviction dies silently and there is no 

537 # self-healing path (the only reseed point is the function 

538 # that no longer runs). Clamping leaves us at worst 

539 # undercounting (the next reseed corrects it) instead of 

540 # entering the permanently-broken negative state. 

541 self._tracked_size_bytes = max(0, self._tracked_size_bytes - size) 1k

542 

543 def __len__(self) -> int: 

544 """Return the number of files currently in ``entries/``. 

545 

546 This is a count of on-disk files, not of keys reachable through 

547 ``make_program_cache_key``. After a ``_KEY_SCHEMA_VERSION`` bump 

548 old entries become unreachable by lookup but remain on disk 

549 until eviction reaps them; ``__len__`` keeps counting them 

550 until then. The same is true for entries written by callers 

551 using arbitrary user keys -- the backend has no way to tell a 

552 live entry from an orphan without knowing the caller's keying 

553 scheme. 

554 """ 

555 # ``_iter_entry_paths`` already filters with ``entry.is_file()``, 

556 # so don't stat each path a second time here. 

557 return sum(1 for _ in self._iter_entry_paths()) 1z$4q5lY!2Z

558 

559 def clear(self) -> None: 

560 # Snapshot stat alongside path so we can refuse to unlink an entry 

561 # that was concurrently replaced by another process between the 

562 # snapshot scan and the unlink. Same stat-guard contract as 

563 # ``_prune_if_stat_unchanged`` and ``_enforce_size_cap``. 

564 snapshot = [] 1xytzrnNul

565 for path in self._iter_entry_paths(): 1xytzrnNul

566 try: 1xytzrnNul

567 snapshot.append((path, path.stat())) 1xytzrnNul

568 except FileNotFoundError: 1n

569 continue 1n

570 for path, st_before in snapshot: 1xytzrnNul

571 _prune_if_stat_unchanged(path, st_before) 1xytzrnNul

572 # Sweep ONLY stale temp files. Deleting a young temp would race with 

573 # another process between ``mkstemp`` and ``os.replace`` and turn its 

574 # write into ``FileNotFoundError`` instead of a successful commit. 

575 self._sweep_stale_tmp_files() 1xytznNul

576 # Remove empty subdirs (best-effort; concurrent writers may re-create). 

577 if self._entries.exists(): 1xytznNul

578 for sub in sorted(self._entries.iterdir(), reverse=True): 1xytznNul

579 if sub.is_dir(): 1xytznNul

580 with contextlib.suppress(OSError): 1xytznNul

581 sub.rmdir() 1xytznNul

582 # The directory is now (almost) empty -- but a concurrent writer may 

583 # have landed a fresh entry between the snapshot and the unlink, and 

584 # young temp files were intentionally preserved. Re-derive the 

585 # tracker from the post-clear state instead of zeroing blindly. 

586 if self._max_size_bytes is not None: 1xytznNul

587 actual = self._compute_total_size() 1l

588 with self._size_lock: 1l

589 self._tracked_size_bytes = actual 1l

590 

591 # -- internals ----------------------------------------------------------- 

592 

593 def _iter_entry_paths(self) -> Iterable[Path]: 

594 # ``os.scandir`` returns ``DirEntry`` objects whose ``is_dir`` / 

595 # ``is_file`` methods consult the cached dirent type from the 

596 # ``readdir`` result on filesystems that report it (ext4, NTFS, ...), 

597 # avoiding a per-entry ``stat`` syscall. ``Path.iterdir`` also wraps 

598 # ``scandir`` but discards the cached type, forcing a separate 

599 # ``stat`` for every ``Path.is_dir`` / ``Path.is_file``. The ``with`` 

600 # blocks release the underlying directory handle deterministically 

601 # when the consumer stops early -- otherwise a leaked handle blocks 

602 # deletes/renames on Windows until GC. 

603 try: 1bxytz$hf4qaocki5eUjrnNul%Y!2Zgd

604 with os.scandir(self._entries) as outer: 1bxytz$hf4qaocki5eUjrnNul%Y!2Zgd

605 for sub in outer: 1bxytz$hf4qaocki5eUjrnNul%Y2Zgd

606 if not sub.is_dir(follow_symlinks=False): 1bxytzhf4qaocki5ejrnNulY2Zgd

607 continue 12

608 try: 1bxytzhf4qaocki5ejrnNulY2Zgd

609 with os.scandir(sub.path) as inner: 1bxytzhf4qaocki5ejrnNulY2Zgd

610 yield from (Path(entry.path) for entry in inner if entry.is_file(follow_symlinks=False)) 1bxytzhf4qaocki5ejrnNulY2Zgd

611 except FileNotFoundError: 1Z

612 continue 1Z

613 except FileNotFoundError: 1!

614 return 1!

615 

616 def _compute_total_size(self) -> int: 

617 """Walk ``entries/`` + ``tmp/`` and return the on-disk byte total. 

618 

619 Used to seed the tracker at open time and to refresh it after every 

620 eviction pass. Best-effort: files that vanish under us during the 

621 walk (concurrent eviction by this or another process) are skipped. 

622 Tracked total may briefly differ from this scan's result under 

623 cross-process contention; the next eviction will reconcile. 

624 """ 

625 total = 0 1bhfaockieUjl%gd

626 for path in self._iter_entry_paths(): 1bhfaockieUjl%gd

627 try: 1baocg

628 total += path.stat().st_size 1baocg

629 except FileNotFoundError: 1g

630 continue 1g

631 return total + self._sum_tmp_sizes() 1bhfaockieUjl%gd

632 

633 def _iter_tmp_entries(self) -> Iterable[os.DirEntry[str]]: 

634 # Mirror ``_iter_entry_paths``: scandir + cached d_type for the 

635 # file/dir filter + deterministic handle close on early exit. 

636 # Yields ``DirEntry`` (not Path) so callers can use ``entry.stat`` 

637 # / ``entry.path`` directly without an extra wrap. 

638 try: 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

639 with os.scandir(self._tmp) as it: 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

640 yield from (entry for entry in it if entry.is_file(follow_symlinks=False)) 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

641 except FileNotFoundError: 1(

642 return 1(

643 

644 def _sum_tmp_sizes(self) -> int: 

645 """Sum sizes of every file in ``tmp/``, skipping vanished entries. 

646 

647 Both ``_compute_total_size`` (open-time seed) and 

648 ``_enforce_size_cap`` (eviction reconciliation) need this -- 

649 temp files occupy disk too, so undercounting them would let 

650 bursts of in-flight writes silently exceed ``max_size_bytes``. 

651 """ 

652 total = 0 1bhfaockieUjl%g(d

653 for entry in self._iter_tmp_entries(): 1bhfaockieUjl%g(d

654 try: 1a

655 total += entry.stat(follow_symlinks=False).st_size 1a

656 except FileNotFoundError: 

657 continue 

658 return total 1bhfaockieUjl%g(d

659 

660 def _sweep_stale_tmp_files(self) -> None: 

661 """Remove temp files left behind by crashed writers. 

662 

663 Age threshold is conservative (``_TMP_STALE_AGE_SECONDS``) so an 

664 in-flight write from another process is not interrupted. Best 

665 effort: a missing file or a permission failure is ignored. 

666 """ 

667 cutoff = time.time() - _TMP_STALE_AGE_SECONDS 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

668 for entry in self._iter_tmp_entries(): 1sABCDbXxytzS$hfEF4q+8PQ01RG3H,pIJaoc*Oki5WMeK'UjrnNul%Y!2Zgm()9TVd-L.#67vw

669 try: 1xta*)

670 if entry.stat(follow_symlinks=False).st_mtime < cutoff: 1xta*)

671 os.unlink(entry.path) 1t*)

672 except (FileNotFoundError, PermissionError): 1)

673 continue 1)

674 

675 def _enforce_size_cap(self) -> None: 

676 if self._max_size_bytes is None: 1bhfaockiejYd

677 return 1Y

678 # Sweep stale temp files first so a long-dead writer's leftovers 

679 # don't drag the apparent size up and force needless eviction. 

680 self._sweep_stale_tmp_files() 1bhfaockiejd

681 entries = [] 1bhfaockiejd

682 total = 0 1bhfaockiejd

683 # Count both committed entries AND surviving temp files: temp files 

684 # occupy disk too, even if they're young. Without this the soft cap 

685 # silently undercounts in-flight writes. 

686 # 

687 # Trade-off under burst concurrency: many young temp files (each 

688 # below the stale-sweep threshold) can push ``total`` above 

689 # ``max_size_bytes`` with only committed entries left to evict. 

690 # That can over-evict committed entries during the burst; once 

691 # the burst subsides and the temps land via ``os.replace`` (or 

692 # are reaped by a later sweep), the cap re-stabilises. This is 

693 # consistent with the documented soft-cap contract -- callers 

694 # that need a hard bound should leave the cap None and prune 

695 # externally. 

696 for path in self._iter_entry_paths(): 1bhfaockiejd

697 try: 1bhfaockiejd

698 st = path.stat() 1bhfaockiejd

699 except FileNotFoundError: 

700 continue 

701 # Carry the full stat so eviction can guard against a concurrent 

702 # os.replace that swapped a fresh entry into this path between 

703 # snapshot and unlink. Eviction below sorts by ``st_atime`` so 

704 # entries that callers actually read recently survive 

705 # write-only churn (true LRU instead of FIFO). 

706 entries.append((st.st_atime, st.st_size, path, st)) 1bhfaockiejd

707 total += st.st_size 1bhfaockiejd

708 total += self._sum_tmp_sizes() 1bhfaockiejd

709 if total <= self._max_size_bytes: 1bhfaockiejd

710 # Re-seed the tracker from the scan: catches drift from 

711 # cross-process writers/deleters that the per-write delta 

712 # accounting wouldn't have observed. Reaching here means the 

713 # tracker was over-cap but the disk truth is under-cap, so 

714 # this assignment is the cheapest reconciliation point we get. 

715 with self._size_lock: 1ok

716 self._tracked_size_bytes = total 1ok

717 return 1ok

718 entries.sort(key=lambda e: e[0]) # oldest atime first 1bhfaciejd

719 for _atime, size, path, st_before in entries: 1bhfaciejd

720 if total <= self._max_size_bytes: 1bhfaciejd

721 break 1bacijd

722 # _prune_if_stat_unchanged refuses if a writer replaced the file 

723 # between snapshot and now, so eviction can't silently delete a 

724 # freshly-committed entry from another process. 

725 try: 1bhfaciejd

726 stat_now = path.stat() 1bhfaciejd

727 except FileNotFoundError: 1d

728 total -= size 1d

729 continue 1d

730 if _stat_key(stat_now) != _stat_key(st_before): 1bhfaciej

731 # File was replaced -- don't unlink, but update ``total`` to 

732 # reflect the replacement's actual size or the cap check 

733 # below could declare us done while still over the limit. 

734 total += stat_now.st_size - size 

735 continue 

736 # Tolerate Windows sharing violations during eviction: another 

737 # process may briefly hold the file open for a read. Skip this 

738 # entry; a later eviction pass will retry. Same outcome as if 

739 # the stat-guard above had triggered. Other PermissionErrors 

740 # (POSIX ACL, Windows non-sharing winerrors) are real config 

741 # problems -- surface them rather than silently exceed the cap. 

742 try: 1bhfaciej

743 _unlink_with_sharing_retry(path) 1bhfaciej

744 total -= size 1bacij

745 except FileNotFoundError: 1hfe

746 pass 

747 except PermissionError as exc: 1hfe

748 if not _is_windows_sharing_violation(exc): 1hfe

749 raise 1hf

750 # Reconcile: after the eviction pass, ``total`` reflects what we 

751 # believe the disk now holds. Re-seed the tracker so the next write 

752 # accumulates from a fresh baseline. 

753 with self._size_lock: 1baciejd

754 self._tracked_size_bytes = total 1baciejd