@@ -597,10 +597,15 @@ def make_program_cache_key(
597597 # (_program.pyx:759). Returning a cached ObjectCode whose mapping-key
598598 # type differs from what the caller's later ``get_kernel`` passes would
599599 # silently miss -- so treat ``"foo"`` and ``b"foo"`` as distinct here.
600+ # Reject anything other than str/bytes/bytearray up front; Program.compile
601+ # would fail at compile time anyway, and persisting a key for an invalid
602+ # input is just a foot-gun.
600603 def _tag_name (n ):
601604 if isinstance (n , (bytes , bytearray )):
602605 return b"b:" + bytes (n )
603- return b"s:" + str (n ).encode ("utf-8" )
606+ if isinstance (n , str ):
607+ return b"s:" + n .encode ("utf-8" )
608+ raise TypeError (f"name_expressions elements must be str, bytes, or bytearray; got { type (n ).__name__ } " )
604609
605610 names = tuple (sorted (_tag_name (n ) for n in name_expressions ))
606611
@@ -988,6 +993,11 @@ def _enforce_size_cap(self) -> None:
988993_ENTRIES_SUBDIR = "entries"
989994_TMP_SUBDIR = "tmp"
990995_SCHEMA_FILE = "SCHEMA_VERSION"
996+ # Temp files older than this are assumed to belong to a crashed writer and
997+ # are eligible for cleanup. Picked large enough that no real ``os.replace``
998+ # write should still be in flight (writes are bounded by mkstemp + write +
999+ # fsync + replace, all fast on healthy disks).
1000+ _TMP_STALE_AGE_SECONDS = 3600
9911001
9921002
9931003_SHARING_VIOLATION_WINERRORS = (32 , 33 ) # ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION
@@ -1114,6 +1124,10 @@ def __init__(
11141124 with contextlib .suppress (FileNotFoundError ):
11151125 entry .unlink ()
11161126 self ._schema_path .write_text (expected )
1127+ # Opportunistic startup sweep of orphaned temp files left by any
1128+ # crashed writers. Age-based so concurrent in-flight writes from
1129+ # other processes are preserved.
1130+ self ._sweep_stale_tmp_files ()
11171131
11181132 # -- key-to-path helpers -------------------------------------------------
11191133
@@ -1219,6 +1233,15 @@ def clear(self) -> None:
12191233 for path in list (self ._iter_entry_paths ()):
12201234 with contextlib .suppress (FileNotFoundError ):
12211235 path .unlink ()
1236+ # The user explicitly asked to wipe this cache, so also drop every
1237+ # temp file we can see (whether stale or in flight from this process).
1238+ # Other processes' in-flight writes will still complete to ``entries``
1239+ # via ``os.replace``, but their staging files are intentionally gone.
1240+ if self ._tmp .exists ():
1241+ for tmp in list (self ._tmp .iterdir ()):
1242+ if tmp .is_file ():
1243+ with contextlib .suppress (FileNotFoundError ):
1244+ tmp .unlink ()
12221245 # Remove empty subdirs (best-effort; concurrent writers may re-create).
12231246 if self ._entries .exists ():
12241247 for sub in sorted (self ._entries .iterdir (), reverse = True ):
@@ -1238,18 +1261,51 @@ def _iter_entry_paths(self) -> Iterable[Path]:
12381261 if entry .is_file ():
12391262 yield entry
12401263
1264+ def _sweep_stale_tmp_files (self ) -> None :
1265+ """Remove temp files left behind by crashed writers.
1266+
1267+ Age threshold is conservative (``_TMP_STALE_AGE_SECONDS``) so an
1268+ in-flight write from another process is not interrupted. Best
1269+ effort: a missing file or a permission failure is ignored.
1270+ """
1271+ if not self ._tmp .exists ():
1272+ return
1273+ cutoff = time .time () - _TMP_STALE_AGE_SECONDS
1274+ for tmp in self ._tmp .iterdir ():
1275+ if not tmp .is_file ():
1276+ continue
1277+ try :
1278+ if tmp .stat ().st_mtime < cutoff :
1279+ tmp .unlink ()
1280+ except (FileNotFoundError , PermissionError ):
1281+ continue
1282+
12411283 def _enforce_size_cap (self ) -> None :
12421284 if self ._max_size_bytes is None :
12431285 return
1286+ # Sweep stale temp files first so a long-dead writer's leftovers
1287+ # don't drag the apparent size up and force needless eviction.
1288+ self ._sweep_stale_tmp_files ()
12441289 entries = []
12451290 total = 0
1291+ # Count both committed entries AND surviving temp files: temp files
1292+ # occupy disk too, even if they're young. Without this the soft cap
1293+ # silently undercounts in-flight writes.
12461294 for path in self ._iter_entry_paths ():
12471295 try :
12481296 st = path .stat ()
12491297 except FileNotFoundError :
12501298 continue
12511299 entries .append ((st .st_mtime , st .st_size , path ))
12521300 total += st .st_size
1301+ if self ._tmp .exists ():
1302+ for tmp in self ._tmp .iterdir ():
1303+ if not tmp .is_file ():
1304+ continue
1305+ try :
1306+ total += tmp .stat ().st_size
1307+ except FileNotFoundError :
1308+ continue
12531309 if total <= self ._max_size_bytes :
12541310 return
12551311 entries .sort () # oldest mtime first
0 commit comments