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

280 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 01:23 +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 pathlib import Path 

23from typing import Any, Callable, Iterable 

24 

25from cuda.core._module import ObjectCode 

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

27 

28from ._abc import ProgramCacheResource, _as_key_bytes, _extract_bytes 

29 

30_ENTRIES_SUBDIR = "entries" 

31_TMP_SUBDIR = "tmp" 

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

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

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

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

36_TMP_STALE_AGE_SECONDS = 3600 

37 

38 

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

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

41 

42 

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

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

45_IS_WINDOWS = os.name == "nt" 

46 

47 

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

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

50 

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

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

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

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

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

56 """ 

57 return (st.st_ino, st.st_size, st.st_mtime_ns) 1sABCDbxytzhfEFqG4HpIJacPieKjrnulgmdLO78vw

58 

59 

60def _default_cache_dir() -> Path: 

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

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

63 """ 

64 return _user_cache_dir() / "program-cache" 1?

65 

66 

67def _with_sharing_retry( 

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

69) -> Any: 

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

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

72 

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

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

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

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

77 (real ACLs, unexpected winerror) propagate immediately. 

78 

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

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

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

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

83 """ 

84 last_exc: PermissionError | None = None 1sABCDbYxytzT%hfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdLO$78vw

85 for delay in _REPLACE_RETRY_DELAYS: 1sABCDbYxytzT%hfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdLO$78vw

86 if delay: 1sABCDbYxytzT%hfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdLO$78vw

87 time.sleep(delay) 1QRSpMe

88 try: 1sABCDbYxytzT%hfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdLO$78vw

89 return op(*args, **kwargs) 1sABCDbYxytzT%hfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdLO$78vw

90 except PermissionError as exc: 1sbYT%hf9QR12SpacXMernumvw

91 if not _is_windows_sharing_violation(exc): 1hf9QR12SpXMerm

92 raise 1hf912Xr

93 last_exc = exc 1QRSpMem

94 if on_exhausted is not None: 1QRSem

95 return on_exhausted(last_exc) 1QRSm

96 assert last_exc is not None # at least one iteration ran and caught a PermissionError 1e

97 raise last_exc 1e

98 

99 

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

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

102 

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

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

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

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

107 

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

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

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

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

112 """ 

113 

114 def _do_replace() -> bool: 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

115 os.replace(tmp_path, target) 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

116 return True 1sABCDbxytzThfEF5qG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

117 

118 return bool(_with_sharing_retry(_do_replace, on_exhausted=lambda _exc: False)) 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

119 

120 

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

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

123 sharing-violation ``PermissionError``. 

124 

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

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

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

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

129 

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

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

132 

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

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

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

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

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

138 of treating them as transient negligible. 

139 """ 

140 

141 def _do_stat_and_read() -> tuple[os.stat_result, bytes]: 1sABCDbYT%EFqQRSGHpIJacMKngmUWdLO78vw

142 return path.stat(), path.read_bytes() 1sABCDbYT%EFqQRSGHpIJacMKngmUWdLO78vw

143 

144 def _exhausted(last_exc: PermissionError) -> None: 1sABCDbYT%EFqQRSGHpIJacMKngmUWdLO78vw

145 raise FileNotFoundError(path) from last_exc 1m

146 

147 return _with_sharing_retry(_do_stat_and_read, on_exhausted=_exhausted) # type: ignore[no-any-return] 1sABCDbYT%EFqQRSGHpIJacMKngmUWdLO78vw

148 

149 

150_UTIME_SUPPORTS_FD = os.utime in os.supports_fd 

151 

152 

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

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

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

156 

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

158 regardless of OS or filesystem default behavior: 

159 

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

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

162 been read once. 

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

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

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

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

167 

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

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

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

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

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

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

174 into deleting a freshly-committed file. 

175 

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

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

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

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

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

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

182 

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

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

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

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

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

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

189 not a corrupt eviction. 

190 

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

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

193 eviction degrades toward FIFO. 

194 """ 

195 new_atime_ns = time.time_ns() 1sABCDbEFqGHpIJacPKgm!UWdLO78vw

196 if _UTIME_SUPPORTS_FD: 1sABCDbEFqGHpIJacPKgm!UWdLO78vw

197 try: 1sABCDbEFqGHpIJacPKgmUWdLO78vw

198 fd = os.open(path, os.O_RDONLY) 1sABCDbEFqGHpIJacPKgmUWdLO78vw

199 except OSError: 1W

200 return 1W

201 try: 1sABCDbEFqGHpIJacPKgmUdLO78vw

202 try: 1sABCDbEFqGHpIJacPKgmUdLO78vw

203 st_now = os.fstat(fd) 1sABCDbEFqGHpIJacPKgmUdLO78vw

204 except OSError: 1U

205 return 1U

206 if _stat_key(st_now) != _stat_key(st_before): 1sABCDbEFqGHpIJacPKgmdLO78vw

207 return 1P

208 with contextlib.suppress(OSError): 1sABCDbEFqGHpIJacPKgmdLO78vw

209 os.utime(fd, ns=(new_atime_ns, st_before.st_mtime_ns)) 1sABCDbEFqGHpIJacPKgmdLO78vw

210 finally: 

211 os.close(fd) 1sABCDbEFqGHpIJacPKgmUdLO78vw

212 return 1sABCDbEFqGHpIJacPKgmdLO78vw

213 

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

215 # documented above. 

216 try: 1sABCDbEFqGHpIJacPKgm!dLO78vw

217 st_now = path.stat() 1sABCDbEFqGHpIJacPKgm!dLO78vw

218 except OSError: 1!

219 return 1!

220 if _stat_key(st_now) != _stat_key(st_before): 1sABCDbEFqGHpIJacPKgmdLO78vw

221 return 1P

222 with contextlib.suppress(OSError): 1sABCDbEFqGHpIJacPKgmdLO78vw

223 os.utime(path, ns=(new_atime_ns, st_before.st_mtime_ns)) 1sABCDbEFqGHpIJacPKgmdLO78vw

224 

225 

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

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

228 :func:`_unlink_with_sharing_retry` would have retried. 

229 

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

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

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

233 configuration problems, not transient contention. 

234 

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

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

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

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

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

240 as transient would silently swallow real errors like a corrupt 

241 ACL. 

242 """ 

243 if not _IS_WINDOWS: 1hf9QR12SpXMerm:

244 return False 1h9X:

245 if not isinstance(exc, PermissionError): 1fQR12SpMerm:

246 return False 

247 winerror = getattr(exc, "winerror", None) 1fQR12SpMerm:

248 if winerror in _SHARING_VIOLATION_WINERRORS: 1fQR12SpMerm:

249 return True 1QRSpMem:

250 return winerror is None and exc.errno == errno.EACCES 1f12r:

251 

252 

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

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

255 

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

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

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

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

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

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

262 transient contention is not turned into a propagated error. 

263 

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

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

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

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

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

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

270 """ 

271 _with_sharing_retry(path.unlink) 1bxytzThf4ackiXMejrnul

272 

273 

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

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

276 

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

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

279 writer before the reader decides to prune. Comparing 

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

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

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

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

284 removed and the next read recompiles. 

285 

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

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

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

289 """ 

290 try: 1xytz4rnNul

291 st_now = path.stat() 1xytz4rnNul

292 except FileNotFoundError: 1N

293 return 1N

294 if _stat_key(st_before) != _stat_key(st_now): 1xytz4rnul

295 return 1y4

296 try: 1xytz4rnul

297 _unlink_with_sharing_retry(path) 1xytz4rnul

298 except FileNotFoundError: 1ru

299 pass 1u

300 except PermissionError as exc: 1r

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

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

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

304 if not _is_windows_sharing_violation(exc): 1r

305 raise 1r

306 

307 

308class FileStreamProgramCache(ProgramCacheResource): 

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

310 

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

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

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

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

315 so the files are directly consumable by external NVIDIA tools 

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

317 

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

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

320 first. 

321 

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

323 

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

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

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

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

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

329 and other ``PermissionError`` codes propagate. 

330 

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

332 

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

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

335 between write and the next directory commit may lose recently 

336 added entries; surviving entries remain consistent. 

337 

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

339 

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

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

342 relevant backend/compiler/runtime fingerprints for its 

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

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

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

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

347 too; nvJitLink-backed PTX entries are deliberately 

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

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

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

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

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

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

354 only requires that the compiler-pinning surface above stays 

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

356 

357 Parameters 

358 ---------- 

359 path: 

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

361 the OS-conventional user cache directory is used: 

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

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

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

365 max_size_bytes: 

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

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

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

369 """ 

370 

371 def __init__( 

372 self, 

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

374 *, 

375 max_size_bytes: int | None = None, 

376 ) -> None: 

377 if max_size_bytes is not None and max_size_bytes <= 0: 1sABCDbYxytzT%hfEF5q,9QR12SG4H-;=pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

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

379 self._root = Path(path) if path is not None else _default_cache_dir() 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

380 self._entries = self._root / _ENTRIES_SUBDIR 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

381 self._tmp = self._root / _TMP_SUBDIR 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

382 self._max_size_bytes = max_size_bytes 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

383 # Permissions (see PR #2399): 

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

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

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

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

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

389 # existing directory is left as-is. 

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

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

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

393 self._root.mkdir(parents=True, exist_ok=True) 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

394 self._entries.mkdir(exist_ok=True) 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

395 self._tmp.mkdir(exist_ok=True, mode=0o700) 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

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

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

398 # other processes are preserved. 

399 self._sweep_stale_tmp_files() 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

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

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

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

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

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

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

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

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

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

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

410 # a cap the tracker is dead weight. 

411 self._size_lock = threading.Lock() 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

412 self._tracked_size_bytes = self._compute_total_size() if max_size_bytes is not None else 0 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

413 

414 # -- key-to-path helpers ------------------------------------------------- 

415 

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

417 k = _as_key_bytes(key) 1sABCDbYxytzT%hfEF5q9QR12SG4HpIJaocPki6XMeK(VjrnNul'Z#30gm!UWdLO$78vw

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

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

420 # ext4 / NTFS). 

421 # 

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

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

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

425 # 

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

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

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

429 # work). 

430 digest = hashlib.sha256(k, usedforsecurity=False).hexdigest() 1sABCDbYxytzT%hfEF5q9QR12SG4HpIJaocPki6XMeK(VjrnNul'Z#30gm!UWdLO$78vw

431 return self._entries / digest[:2] / digest[2:] 1sABCDbYxytzT%hfEF5q9QR12SG4HpIJaocPki6XMeK(VjrnNul'Z#30gm!UWdLO$78vw

432 

433 # -- mapping API --------------------------------------------------------- 

434 

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

436 path = self._path_for_key(key) 1sABCDbYT%EFqQRSGHpIJacMKngmUWdLO78vw

437 try: 1sABCDbYT%EFqQRSGHpIJacMKngmUWdLO78vw

438 # The helper retries on Windows transient sharing-violation 

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

440 # into a spurious propagated error. 

441 st, data = _stat_and_read_with_sharing_retry(path) 1sABCDbYT%EFqQRSGHpIJacMKngmUWdLO78vw

442 except FileNotFoundError: 1sbYT%QRSacMnmvw

443 raise KeyError(key) from None 1sbYT%QRSacMnmvw

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

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

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

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

448 # rather than true LRU. 

449 _touch_atime(path, st) 1sABCDbEFqGHpIJacKgmUWdLO78vw

450 return data 1sABCDbEFqGHpIJacKgmUWdLO78vw

451 

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

453 data = _extract_bytes(value) 1sABCDbYxytzThfEF5q,9QR12SG4H-pIJaocPki6XMeK(VjrnNulZ#30gm!UWdL$vw

454 target = self._path_for_key(key) 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeK(VjrnNulZ#30gm!UWdL$vw

455 target.parent.mkdir(parents=True, exist_ok=True) 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeK(VjrnNulZ#30gm!UWdL$vw

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

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

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

459 # without it, every subsequent write would crash with 

460 # FileNotFoundError even though we could trivially recover. 

461 self._tmp.mkdir(parents=True, exist_ok=True) 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeK(VjrnNulZ#30gm!UWdL$vw

462 

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

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

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

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

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

468 old_size = 0 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeK(VjrnNulZ#30gm!UWdL$vw

469 if self._max_size_bytes is not None: 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeK(VjrnNulZ#30gm!UWdL$vw

470 try: 1bhfaockieVjlgd

471 old_size = target.stat().st_size 1bhfaockieVjlgd

472 except FileNotFoundError: 1bhfaockieVjlgd

473 old_size = 0 1bhfaockieVjlgd

474 

475 fd, tmp_name = tempfile.mkstemp(prefix="entry-", dir=self._tmp) 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeK(VjrnNulZ#30gm!UWdL$vw

476 tmp_path = Path(tmp_name) 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

477 try: 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

478 with os.fdopen(fd, "wb") as fh: 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

479 fh.write(data) 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

480 fh.flush() 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

481 os.fsync(fh.fileno()) 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

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

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

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

485 # Non-sharing PermissionErrors and all POSIX PermissionErrors 

486 # propagate immediately (real config problem). 

487 if not _replace_with_sharing_retry(tmp_path, target): 1sABCDbYxytzThfEF5q9QR12SG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

488 with contextlib.suppress(FileNotFoundError): 1QRS

489 tmp_path.unlink() 1QRS

490 return 1QRS

491 except BaseException: 1Y912

492 with contextlib.suppress(FileNotFoundError): 1Y912

493 tmp_path.unlink() 1Y912

494 raise 1Y912

495 

496 if self._max_size_bytes is None: 1sABCDbxytzThfEF5qG4HpIJaocPki6XMeKVjrnNulZ#30gm!UWdL$vw

497 return 1sABCDxytzTEF5qG4HpIJP6XMKrnNuZ#30gm!UWL$vw

498 

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

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

501 new_size = len(data) 1bhfaockieVjlgd

502 with self._size_lock: 1bhfaockieVjlgd

503 self._tracked_size_bytes += new_size - old_size 1bhfaockieVjlgd

504 over_cap = self._tracked_size_bytes > self._max_size_bytes 1bhfaockieVjlgd

505 if over_cap: 1bhfaockieVjlgd

506 self._enforce_size_cap() 1bhfaociejd

507 

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

509 path = self._path_for_key(key) 1TkXM'

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

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

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

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

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

515 size = 0 1TkXM'

516 if self._max_size_bytes is not None: 1TkXM'

517 try: 1k'

518 size = path.stat().st_size 1k'

519 except FileNotFoundError: 1'

520 raise KeyError(key) from None 1'

521 try: 1TkXM

522 _unlink_with_sharing_retry(path) 1TkXM

523 except FileNotFoundError: 1TX

524 raise KeyError(key) from None 1T

525 if self._max_size_bytes is not None: 1TkM

526 with self._size_lock: 1k

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

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

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

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

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

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

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

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

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

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

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

538 # entering the permanently-broken negative state. 

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

540 

541 def __len__(self) -> int: 

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

543 

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

545 ``make_program_cache_key``. After a ``_KEY_SCHEMA_VERSION`` bump 

546 old entries become unreachable by lookup but remain on disk 

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

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

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

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

551 scheme. 

552 """ 

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

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

555 return sum(1 for _ in self._iter_entry_paths()) 1z%5q6lZ#30

556 

557 def clear(self) -> None: 

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

559 # that was concurrently replaced by another process between the 

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

561 # ``_prune_if_stat_unchanged`` and ``_enforce_size_cap``. 

562 snapshot = [] 1xytzrnNul

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

564 try: 1xytzrnNul

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

566 except FileNotFoundError: 1n

567 continue 1n

568 for path, st_before in snapshot: 1xytzrnNul

569 _prune_if_stat_unchanged(path, st_before) 1xytzrnNul

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

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

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

573 self._sweep_stale_tmp_files() 1xytznNul

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

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

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

577 if sub.is_dir(): 1xytznNul

578 with contextlib.suppress(OSError): 1xytznNul

579 sub.rmdir() 1xytznNul

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

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

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

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

584 if self._max_size_bytes is not None: 1xytznNul

585 actual = self._compute_total_size() 1l

586 with self._size_lock: 1l

587 self._tracked_size_bytes = actual 1l

588 

589 # -- internals ----------------------------------------------------------- 

590 

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

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

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

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

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

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

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

598 # blocks release the underlying directory handle deterministically 

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

600 # deletes/renames on Windows until GC. 

601 try: 1bxytz%hf5qaocki6eVjrnNul'Z#30gdO

602 with os.scandir(self._entries) as outer: 1bxytz%hf5qaocki6eVjrnNul'Z#30gdO

603 for sub in outer: 1bxytz%hf5qaocki6eVjrnNul'Z30gdO

604 if not sub.is_dir(follow_symlinks=False): 1bxytzhf5qaocki6ejrnNulZ30gdO

605 continue 13

606 try: 1bxytzhf5qaocki6ejrnNulZ30gdO

607 with os.scandir(sub.path) as inner: 1bxytzhf5qaocki6ejrnNulZ30gdO

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

609 except FileNotFoundError: 10

610 continue 10

611 except FileNotFoundError: 1#

612 return 1#

613 

614 def _compute_total_size(self) -> int: 

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

616 

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

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

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

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

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

622 """ 

623 total = 0 1bhfaockieVjl'gdO

624 for path in self._iter_entry_paths(): 1bhfaockieVjl'gdO

625 try: 1baocgO

626 total += path.stat().st_size 1baocgO

627 except FileNotFoundError: 1g

628 continue 1g

629 return total + self._sum_tmp_sizes() 1bhfaockieVjl'gdO

630 

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

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

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

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

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

636 try: 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

637 with os.scandir(self._tmp) as it: 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

638 yield from (entry for entry in it if entry.is_file(follow_symlinks=False)) 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

639 except FileNotFoundError: 1)

640 return 1)

641 

642 def _sum_tmp_sizes(self) -> int: 

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

644 

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

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

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

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

649 """ 

650 total = 0 1bhfaockieVjl'g)dO

651 for entry in self._iter_tmp_entries(): 1bhfaockieVjl'g)dO

652 try: 1a

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

654 except FileNotFoundError: 

655 continue 

656 return total 1bhfaockieVjl'g)dO

657 

658 def _sweep_stale_tmp_files(self) -> None: 

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

660 

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

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

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

664 """ 

665 cutoff = time.time() - _TMP_STALE_AGE_SECONDS 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

666 for entry in self._iter_tmp_entries(): 1sABCDbYxytzT%hfEF5q,9QR12SG4H-pIJaoc+Pki6XMeK(VjrnNul'Z#30gm)*!UWd.L/O$78vw

667 try: 1xta+*

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

669 os.unlink(entry.path) 1t+*

670 except (FileNotFoundError, PermissionError): 1*

671 continue 1*

672 

673 def _enforce_size_cap(self) -> None: 

674 if self._max_size_bytes is None: 1bhfaockiejZd

675 return 1Z

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

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

678 self._sweep_stale_tmp_files() 1bhfaockiejd

679 entries = [] 1bhfaockiejd

680 total = 0 1bhfaockiejd

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

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

683 # silently undercounts in-flight writes. 

684 # 

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

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

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

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

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

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

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

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

693 # externally. 

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

695 try: 1bhfaockiejd

696 st = path.stat() 1bhfaockiejd

697 except FileNotFoundError: 

698 continue 

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

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

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

702 # entries that callers actually read recently survive 

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

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

705 total += st.st_size 1bhfaockiejd

706 total += self._sum_tmp_sizes() 1bhfaockiejd

707 if total <= self._max_size_bytes: 1bhfaockiejd

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

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

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

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

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

713 with self._size_lock: 1ok

714 self._tracked_size_bytes = total 1ok

715 return 1ok

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

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

718 if total <= self._max_size_bytes: 1bhfaciejd

719 break 1bacijd

720 # _prune_if_stat_unchanged refuses if a writer replaced the file 

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

722 # freshly-committed entry from another process. 

723 try: 1bhfaciejd

724 stat_now = path.stat() 1bhfaciejd

725 except FileNotFoundError: 1d

726 total -= size 1d

727 continue 1d

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

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

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

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

732 total += stat_now.st_size - size 

733 continue 

734 # Tolerate Windows sharing violations during eviction: another 

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

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

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

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

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

740 try: 1bhfaciej

741 _unlink_with_sharing_retry(path) 1bhfaciej

742 total -= size 1bacij

743 except FileNotFoundError: 1hfe

744 pass 

745 except PermissionError as exc: 1hfe

746 if not _is_windows_sharing_violation(exc): 1hfe

747 raise 1hf

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

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

750 # accumulates from a fresh baseline. 

751 with self._size_lock: 1baciejd

752 self._tracked_size_bytes = total 1baciejd