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

284 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-22 01:37 +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 Iterable 

24 

25from cuda.core._module import ObjectCode 

26 

27from ._abc import ProgramCacheResource, _as_key_bytes, _extract_bytes 

28 

29_ENTRIES_SUBDIR = "entries" 

30_TMP_SUBDIR = "tmp" 

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

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

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

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

35_TMP_STALE_AGE_SECONDS = 3600 

36 

37 

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

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

40 

41 

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

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

44_IS_WINDOWS = os.name == "nt" 

45 

46 

47def _stat_key(st: os.stat_result) -> tuple: 

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

49 

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

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

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

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

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

55 """ 

56 return (st.st_ino, st.st_size, st.st_mtime_ns) 1quvwxbnolpfeyzmARBkCDacMgdEhFKVWrs

57 

58 

59def _default_cache_dir() -> Path: 

60 """OS-conventional default location for the file-stream cache. 

61 

62 Resolves to the user-cache root for the calling user, with a 

63 ``program-cache`` leaf so future tooling can place sibling caches 

64 under the same ``cuda-python`` vendor directory: 

65 

66 * Linux: ``$XDG_CACHE_HOME/cuda-python/program-cache`` 

67 (default ``~/.cache/cuda-python/program-cache`` per the XDG Base 

68 Directory spec). 

69 * Windows: ``%LOCALAPPDATA%\\cuda-python\\program-cache`` 

70 (Windows uses local AppData -- caches don't roam; falls back to 

71 ``~/AppData/Local`` if the env var is unset). 

72 

73 CUDA does not support macOS, so no macOS branch is provided. 

74 """ 

75 if _IS_WINDOWS: 15

76 local_app_data = os.environ.get("LOCALAPPDATA") 15

77 root = Path(local_app_data) if local_app_data else Path.home() / "AppData" / "Local" 15

78 else: 

79 xdg = os.environ.get("XDG_CACHE_HOME") 15

80 root = Path(xdg) if xdg else Path.home() / ".cache" 15

81 return root / "cuda-python" / "program-cache" 15

82 

83 

84def _with_sharing_retry(op, *args, on_exhausted=None, **kwargs): 

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

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

87 

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

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

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

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

92 (real ACLs, unexpected winerror) propagate immediately. 

93 

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

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

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

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

98 """ 

99 last_exc: PermissionError | None = None 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdELhFKXVWrs

100 for delay in _REPLACE_RETRY_DELAYS: 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdELhFKXVWrs

101 if delay: 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdELhFKXVWrs

102 time.sleep(delay) 1GHIktd

103 try: 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdELhFKXVWrs

104 return op(*args, **kwargs) 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdELhFKXVWrs

105 except PermissionError as exc: 1qbOJZfeUGHPQIkacNtdrs

106 if not _is_windows_sharing_violation(exc): 1feUGHPQIkNtd

107 raise 1feUPQN

108 last_exc = exc 1GHIktd

109 if on_exhausted is not None: 1GHId

110 return on_exhausted(last_exc) 1GHI

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

112 raise last_exc 1d

113 

114 

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

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

117 

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

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

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

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

122 

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

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

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

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

127 """ 

128 

129 def _do_replace() -> bool: 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs

130 os.replace(tmp_path, target) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs

131 return True 1quvwxbnolpJfeyzSmARBkCDajcMigTNtdELhFXrs

132 

133 return _with_sharing_retry(_do_replace, on_exhausted=lambda _exc: False) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs

134 

135 

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

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

138 sharing-violation ``PermissionError``. 

139 

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

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

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

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

144 

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

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

147 

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

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

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

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

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

153 of treating them as transient negligible. 

154 """ 

155 

156 def _do_stat_and_read() -> tuple[os.stat_result, bytes]: 1quvwxbOJZyzmGHIABkCDactEFKVWrs

157 return path.stat(), path.read_bytes() 1quvwxbOJZyzmGHIABkCDactEFKVWrs

158 

159 def _exhausted(last_exc): 1quvwxbOJZyzmGHIABkCDactEFKVWrs

160 raise FileNotFoundError(path) from last_exc 

161 

162 return _with_sharing_retry(_do_stat_and_read, on_exhausted=_exhausted) 1quvwxbOJZyzmGHIABkCDactEFKVWrs

163 

164 

165_UTIME_SUPPORTS_FD = os.utime in os.supports_fd 

166 

167 

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

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

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

171 

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

173 regardless of OS or filesystem default behavior: 

174 

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

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

177 been read once. 

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

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

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

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

182 

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

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

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

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

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

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

189 into deleting a freshly-committed file. 

190 

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

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

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

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

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

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

197 

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

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

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

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

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

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

204 not a corrupt eviction. 

205 

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

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

208 eviction degrades toward FIFO. 

209 """ 

210 new_atime_ns = time.time_ns() 1quvwxbyzmABkCDacMEFKVWrs

211 if _UTIME_SUPPORTS_FD: 1quvwxbyzmABkCDacMEFKVWrs

212 try: 1quvwxbyzmABkCDacMEFKVWrs

213 fd = os.open(path, os.O_RDONLY) 1quvwxbyzmABkCDacMEFKVWrs

214 except OSError: 

215 return 

216 try: 1quvwxbyzmABkCDacMEFKVWrs

217 try: 1quvwxbyzmABkCDacMEFKVWrs

218 st_now = os.fstat(fd) 1quvwxbyzmABkCDacMEFKVWrs

219 except OSError: 

220 return 

221 if _stat_key(st_now) != _stat_key(st_before): 1quvwxbyzmABkCDacMEFKVWrs

222 return 1M

223 with contextlib.suppress(OSError): 1quvwxbyzmABkCDacMEFKVWrs

224 os.utime(fd, ns=(new_atime_ns, st_before.st_mtime_ns)) 1quvwxbyzmABkCDacMEFKVWrs

225 finally: 

226 os.close(fd) 1quvwxbyzmABkCDacMEFKVWrs

227 return 1quvwxbyzmABkCDacMEFKVWrs

228 

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

230 # documented above. 

231 try: 

232 st_now = path.stat() 

233 except OSError: 

234 return 

235 if _stat_key(st_now) != _stat_key(st_before): 

236 return 

237 with contextlib.suppress(OSError): 

238 os.utime(path, ns=(new_atime_ns, st_before.st_mtime_ns)) 

239 

240 

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

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

243 :func:`_unlink_with_sharing_retry` would have retried. 

244 

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

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

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

248 configuration problems, not transient contention. 

249 

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

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

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

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

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

255 as transient would silently swallow real errors like a corrupt 

256 ACL. 

257 """ 

258 if not _IS_WINDOWS: 1feUGHPQIkNtd4

259 return False 1fUN4

260 if not isinstance(exc, PermissionError): 1eGHPQIktd4

261 return False 

262 winerror = getattr(exc, "winerror", None) 1eGHPQIktd4

263 if winerror in _SHARING_VIOLATION_WINERRORS: 1eGHPQIktd4

264 return True 1GHIktd4

265 return winerror is None and exc.errno == errno.EACCES 1ePQ4

266 

267 

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

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

270 

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

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

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

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

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

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

277 transient contention is not turned into a propagated error. 

278 

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

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

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

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

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

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

285 """ 

286 _with_sharing_retry(path.unlink) 1bnolpJfeRacigNtdh

287 

288 

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

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

291 

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

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

294 writer before the reader decides to prune. Comparing 

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

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

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

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

299 removed and the next read recompiles. 

300 

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

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

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

304 """ 

305 try: 1nolpR

306 st_now = path.stat() 1nolpR

307 except FileNotFoundError: 

308 return 

309 if _stat_key(st_before) != _stat_key(st_now): 1nolpR

310 return 1oR

311 try: 1nolpR

312 _unlink_with_sharing_retry(path) 1nolpR

313 except FileNotFoundError: 

314 pass 

315 except PermissionError as exc: 

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

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

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

319 if not _is_windows_sharing_violation(exc): 

320 raise 

321 

322 

323class FileStreamProgramCache(ProgramCacheResource): 

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

325 

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

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

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

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

330 so the files are directly consumable by external NVIDIA tools 

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

332 

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

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

335 first. 

336 

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

338 

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

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

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

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

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

344 and other ``PermissionError`` codes propagate. 

345 

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

347 

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

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

350 between write and the next directory commit may lose recently 

351 added entries; surviving entries remain consistent. 

352 

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

354 

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

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

357 relevant backend/compiler/runtime fingerprints for its 

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

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

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

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

362 too; nvJitLink-backed PTX entries are deliberately 

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

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

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

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

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

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

369 only requires that the compiler-pinning surface above stays 

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

371 

372 Parameters 

373 ---------- 

374 path: 

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

376 the OS-conventional user cache directory is used: 

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

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

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

380 max_size_bytes: 

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

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

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

384 """ 

385 

386 def __init__( 

387 self, 

388 path: str | os.PathLike | None = None, 

389 *, 

390 max_size_bytes: int | None = None, 

391 ) -> None: 

392 if max_size_bytes is not None and max_size_bytes <= 0: 1quvwxbOnolpJZfeyzSm2UGHPQIARB367kCDajc1MigTNtdE0LhFKXVWrs

393 raise ValueError("max_size_bytes must be positive or None (0 would evict every write)") 167

394 self._root = Path(path) if path is not None else _default_cache_dir() 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

395 self._entries = self._root / _ENTRIES_SUBDIR 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

396 self._tmp = self._root / _TMP_SUBDIR 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

397 self._max_size_bytes = max_size_bytes 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

398 self._root.mkdir(parents=True, exist_ok=True) 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

399 self._entries.mkdir(exist_ok=True) 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

400 self._tmp.mkdir(exist_ok=True) 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

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

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

403 # other processes are preserved. 

404 self._sweep_stale_tmp_files() 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

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

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

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

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

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

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

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

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

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

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

415 # a cap the tracker is dead weight. 

416 self._size_lock = threading.Lock() 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

417 self._tracked_size_bytes = self._compute_total_size() if max_size_bytes is not None else 0 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

418 

419 # -- key-to-path helpers ------------------------------------------------- 

420 

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

422 k = _as_key_bytes(key) 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFKXVWrs

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

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

425 # ext4 / NTFS). 

426 # 

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

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

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

430 # 

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

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

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

434 # work). 

435 digest = hashlib.sha256(k, usedforsecurity=False).hexdigest() 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFKXVWrs

436 return self._entries / digest[:2] / digest[2:] 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFKXVWrs

437 

438 # -- mapping API --------------------------------------------------------- 

439 

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

441 path = self._path_for_key(key) 1quvwxbOJZyzmGHIABkCDactEFKVWrs

442 try: 1quvwxbOJZyzmGHIABkCDactEFKVWrs

443 # The helper retries on Windows transient sharing-violation 

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

445 # into a spurious propagated error. 

446 st, data = _stat_and_read_with_sharing_retry(path) 1quvwxbOJZyzmGHIABkCDactEFKVWrs

447 except FileNotFoundError: 1qbOJZGHIactrs

448 raise KeyError(key) from None 1qbOJZGHIactrs

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

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

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

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

453 # rather than true LRU. 

454 _touch_atime(path, st) 1quvwxbyzmABkCDacEFKVWrs

455 return data 1quvwxbyzmABkCDacEFKVWrs

456 

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

458 data = _extract_bytes(value) 1quvwxbOnolpJfeyzSm2UGHPQIARB3kCDajcMigTNtdE0LhFXrs

459 target = self._path_for_key(key) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs

460 target.parent.mkdir(parents=True, exist_ok=True) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs

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

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

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

464 # without it, every subsequent write would crash with 

465 # FileNotFoundError even though we could trivially recover. 

466 self._tmp.mkdir(parents=True, exist_ok=True) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs

467 

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

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

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

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

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

473 old_size = 0 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs

474 if self._max_size_bytes is not None: 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs

475 try: 1bfeajcigdLh

476 old_size = target.stat().st_size 1bfeajcigdLh

477 except FileNotFoundError: 1bfeajcigdLh

478 old_size = 0 1bfeajcigdLh

479 

480 fd, tmp_name = tempfile.mkstemp(prefix="entry-", dir=self._tmp) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs

481 tmp_path = Path(tmp_name) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs

482 try: 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs

483 with os.fdopen(fd, "wb") as fh: 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs

484 fh.write(data) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs

485 fh.flush() 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs

486 os.fsync(fh.fileno()) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs

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

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

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

490 # Non-sharing PermissionErrors and all POSIX PermissionErrors 

491 # propagate immediately (real config problem). 

492 if not _replace_with_sharing_retry(tmp_path, target): 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs

493 with contextlib.suppress(FileNotFoundError): 1GHI

494 tmp_path.unlink() 1GHI

495 return 1GHI

496 except BaseException: 1OUPQ

497 with contextlib.suppress(FileNotFoundError): 1OUPQ

498 tmp_path.unlink() 1OUPQ

499 raise 1OUPQ

500 

501 if self._max_size_bytes is None: 1quvwxbnolpJfeyzSmARBkCDajcMigTNtdELhFXrs

502 return 1quvwxnolpJyzSmARBkCDMTNtEFXrs

503 

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

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

506 new_size = len(data) 1bfeajcigdLh

507 with self._size_lock: 1bfeajcigdLh

508 self._tracked_size_bytes += new_size - old_size 1bfeajcigdLh

509 over_cap = self._tracked_size_bytes > self._max_size_bytes 1bfeajcigdLh

510 if over_cap: 1bfeajcigdLh

511 self._enforce_size_cap() 1bfeajcgdh

512 

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

514 path = self._path_for_key(key) 1JiNt

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

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

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

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

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

520 size = 0 1JiNt

521 if self._max_size_bytes is not None: 1JiNt

522 try: 1i

523 size = path.stat().st_size 1i

524 except FileNotFoundError: 

525 raise KeyError(key) from None 

526 try: 1JiNt

527 _unlink_with_sharing_retry(path) 1JiNt

528 except FileNotFoundError: 1JN

529 raise KeyError(key) from None 1J

530 if self._max_size_bytes is not None: 1Jit

531 with self._size_lock: 1i

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

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

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

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

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

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

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

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

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

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

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

543 # entering the permanently-broken negative state. 

544 self._tracked_size_bytes = max(0, self._tracked_size_bytes - size) 1i

545 

546 def __len__(self) -> int: 

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

548 

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

550 ``make_program_cache_key``. After a ``_KEY_SCHEMA_VERSION`` bump 

551 old entries become unreachable by lookup but remain on disk 

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

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

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

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

556 scheme. 

557 """ 

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

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

560 return sum(1 for _ in self._iter_entry_paths()) 1pZSmT

561 

562 def clear(self) -> None: 

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

564 # that was concurrently replaced by another process between the 

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

566 # ``_prune_if_stat_unchanged`` and ``_enforce_size_cap``. 

567 snapshot = [] 1nolp

568 for path in self._iter_entry_paths(): 1nolp

569 try: 1nolp

570 snapshot.append((path, path.stat())) 1nolp

571 except FileNotFoundError: 

572 continue 

573 for path, st_before in snapshot: 1nolp

574 _prune_if_stat_unchanged(path, st_before) 1nolp

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

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

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

578 self._sweep_stale_tmp_files() 1nolp

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

580 if self._entries.exists(): 1nolp

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

582 if sub.is_dir(): 1nolp

583 with contextlib.suppress(OSError): 1nolp

584 sub.rmdir() 1nolp

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

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

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

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

589 if self._max_size_bytes is not None: 1nolp

590 actual = self._compute_total_size() 

591 with self._size_lock: 

592 self._tracked_size_bytes = actual 

593 

594 # -- internals ----------------------------------------------------------- 

595 

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

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

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

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

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

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

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

603 # blocks release the underlying directory handle deterministically 

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

605 # deletes/renames on Windows until GC. 

606 try: 1bnolpZfeSmajcigTdLhK

607 with os.scandir(self._entries) as outer: 1bnolpZfeSmajcigTdLhK

608 for sub in outer: 1bnolpZfeSmajcigTdLhK

609 if not sub.is_dir(follow_symlinks=False): 1bnolpfeSmajcigTdhK

610 continue 

611 try: 1bnolpfeSmajcigTdhK

612 with os.scandir(sub.path) as inner: 1bnolpfeSmajcigTdhK

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

614 except FileNotFoundError: 

615 continue 

616 except FileNotFoundError: 

617 return 

618 

619 def _compute_total_size(self) -> int: 

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

621 

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

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

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

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

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

627 """ 

628 total = 0 1bfeajcigdLhK

629 for path in self._iter_entry_paths(): 1bfeajcigdLhK

630 try: 1bajcK

631 total += path.stat().st_size 1bajcK

632 except FileNotFoundError: 

633 continue 

634 return total + self._sum_tmp_sizes() 1bfeajcigdLhK

635 

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

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

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

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

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

641 try: 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

642 with os.scandir(self._tmp) as it: 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

643 yield from (entry for entry in it if entry.is_file(follow_symlinks=False)) 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

644 except FileNotFoundError: 

645 return 

646 

647 def _sum_tmp_sizes(self) -> int: 

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

649 

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

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

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

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

654 """ 

655 total = 0 1bfeajcigdLhK

656 for entry in self._iter_tmp_entries(): 1bfeajcigdLhK

657 try: 1a

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

659 except FileNotFoundError: 

660 continue 

661 return total 1bfeajcigdLhK

662 

663 def _sweep_stale_tmp_files(self) -> None: 

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

665 

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

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

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

669 """ 

670 cutoff = time.time() - _TMP_STALE_AGE_SECONDS 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

671 for entry in self._iter_tmp_entries(): 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs

672 try: 1nla1

673 if entry.stat(follow_symlinks=False).st_mtime < cutoff: 1nla1

674 os.unlink(entry.path) 1l1

675 except (FileNotFoundError, PermissionError): 

676 continue 

677 

678 def _enforce_size_cap(self) -> None: 

679 if self._max_size_bytes is None: 1bfeajcigdh

680 return 

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

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

683 self._sweep_stale_tmp_files() 1bfeajcigdh

684 entries = [] 1bfeajcigdh

685 total = 0 1bfeajcigdh

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

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

688 # silently undercounts in-flight writes. 

689 # 

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

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

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

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

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

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

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

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

698 # externally. 

699 for path in self._iter_entry_paths(): 1bfeajcigdh

700 try: 1bfeajcigdh

701 st = path.stat() 1bfeajcigdh

702 except FileNotFoundError: 

703 continue 

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

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

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

707 # entries that callers actually read recently survive 

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

709 entries.append((st.st_atime, st.st_size, path, st)) 1bfeajcigdh

710 total += st.st_size 1bfeajcigdh

711 total += self._sum_tmp_sizes() 1bfeajcigdh

712 if total <= self._max_size_bytes: 1bfeajcigdh

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

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

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

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

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

718 with self._size_lock: 1ji

719 self._tracked_size_bytes = total 1ji

720 return 1ji

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

722 for _atime, size, path, st_before in entries: 1bfeacgdh

723 if total <= self._max_size_bytes: 1bfeacgdh

724 break 1bacgh

725 # _prune_if_stat_unchanged refuses if a writer replaced the file 

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

727 # freshly-committed entry from another process. 

728 try: 1bfeacgdh

729 stat_now = path.stat() 1bfeacgdh

730 except FileNotFoundError: 

731 total -= size 

732 continue 

733 if _stat_key(stat_now) != _stat_key(st_before): 1bfeacgdh

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

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

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

737 total += stat_now.st_size - size 

738 continue 

739 # Tolerate Windows sharing violations during eviction: another 

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

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

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

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

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

745 try: 1bfeacgdh

746 _unlink_with_sharing_retry(path) 1bfeacgdh

747 total -= size 1bacgh

748 except FileNotFoundError: 1fed

749 pass 

750 except PermissionError as exc: 1fed

751 if not _is_windows_sharing_violation(exc): 1fed

752 raise 1fe

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

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

755 # accumulates from a fresh baseline. 

756 with self._size_lock: 1bacgdh

757 self._tracked_size_bytes = total 1bacgdh