@@ -1550,84 +1550,57 @@ def test_filestream_cache_clear_does_not_break_concurrent_writer(tmp_path):
15501550
15511551
15521552def test_filestream_cache_size_cap_does_not_unlink_replaced_file (tmp_path ):
1553- """``_enforce_size_cap``'s eviction loop must compare the snapshot stat
1553+ """The PRODUCTION ``_enforce_size_cap`` must compare the snapshot stat
15541554 to the current stat before unlinking; if the file was replaced under
1555- us (a concurrent writer's os.replace), the unlink is skipped.
1556-
1557- Race injection: subclass the cache and override ``_iter_entry_paths`` to
1558- yield the entry paths AND, on the second invocation (the eviction
1559- loop's re-stat), have the wrapper invalidate a target by os.replace-ing
1560- it before the eviction loop calls ``path.stat()``.
1555+ us (a concurrent writer's ``os.replace``), the unlink is skipped.
1556+
1557+ Race injection without reimplementing the method: subclass the cache
1558+ and override only ``_iter_entry_paths`` so that the cleanup code
1559+ *after* the generator's last yield runs an ``os.replace`` on path_a.
1560+ Python's for-loop calls ``next()`` until ``StopIteration``; the
1561+ generator code after its last yield runs at that ``StopIteration``,
1562+ which is exactly between ``_enforce_size_cap``'s scan loop and its
1563+ eviction loop. Eviction's per-entry re-stat then sees a different
1564+ stat for path_a and the production code's stat-guard must skip it.
15611565 """
15621566 import os as _os
15631567
15641568 from cuda .core .utils import FileStreamProgramCache
15651569
1566- cap = 4000
1570+ # Cap fits two entries (each ~2123 bytes on disk for a 2000-byte
1571+ # payload, including pickle overhead) but not three.
1572+ cap = 5000
15671573 root = tmp_path / "fc"
15681574 with FileStreamProgramCache (root , max_size_bytes = cap ) as cache :
15691575 cache [b"a" ] = _fake_object_code (b"A" * 2000 , name = "a" )
15701576 time .sleep (0.02 )
15711577 cache [b"b" ] = _fake_object_code (b"B" * 2000 , name = "b" )
15721578 path_a = cache ._path_for_key (b"a" )
1579+ assert path_a .exists (), "cap too small -- 'a' was evicted before the test ran"
15731580
1574- # Reopen and simulate the race: replace path_a's contents on disk
1575- # AFTER _enforce_size_cap has already snapshotted its stat. We do that
1576- # by subclassing the cache and intercepting the `for ... in entries`
1577- # eviction loop -- mutating the file between the snapshot list build
1578- # and the per-entry unlink check.
15791581 class _RaceCache (FileStreamProgramCache ):
1580- def _enforce_size_cap (self ):
1581- # Mirror the parent's scan + race injection + unlink path.
1582- if self ._max_size_bytes is None :
1583- return
1584- self ._sweep_stale_tmp_files ()
1585- entries = []
1586- total = 0
1587- for path in self ._iter_entry_paths ():
1588- try :
1589- st = path .stat ()
1590- except FileNotFoundError :
1591- continue
1592- entries .append ((st .st_mtime , st .st_size , path , st ))
1593- total += st .st_size
1594- if total <= self ._max_size_bytes :
1595- return
1596- entries .sort (key = lambda e : e [0 ])
1597- # Race injection: replace path_a's bytes via os.replace BEFORE
1598- # the eviction loop re-stats it.
1599- tmp = path_a .parent / "_inflight"
1600- tmp .write_bytes (b"\x80 \x05 fresh-by-other-writer-" * 32 )
1601- _os .replace (tmp , path_a )
1602- # Now run the parent's eviction loop using the SAME snapshot.
1603- for _mtime , size , path , st_before in entries :
1604- if total <= self ._max_size_bytes :
1605- return
1606- try :
1607- stat_now = path .stat ()
1608- except FileNotFoundError :
1609- total -= size
1610- continue
1611- if (stat_now .st_ino , stat_now .st_size , stat_now .st_mtime_ns ) != (
1612- st_before .st_ino ,
1613- st_before .st_size ,
1614- st_before .st_mtime_ns ,
1615- ):
1616- total += stat_now .st_size - size
1617- continue
1618- import contextlib
1619-
1620- with contextlib .suppress (FileNotFoundError ):
1621- path .unlink ()
1622- total -= size
1582+ race_armed = True
1583+
1584+ def _iter_entry_paths (self ):
1585+ yield from super ()._iter_entry_paths ()
1586+ # Generator cleanup runs at StopIteration, between
1587+ # _enforce_size_cap's scan and its eviction loop. Fire the race
1588+ # here exactly once.
1589+ if _RaceCache .race_armed and path_a .exists ():
1590+ _RaceCache .race_armed = False
1591+ tmp = path_a .parent / "_inflight"
1592+ tmp .write_bytes (b"\x80 \x05 fresh-by-other-writer-" * 32 )
1593+ _os .replace (tmp , path_a )
16231594
16241595 with _RaceCache (root , max_size_bytes = cap ) as cache :
1625- # Trigger eviction by adding 'c'; eviction will scan, then
1626- # _RaceCache replaces path_a, then eviction re-stats and skips it.
1596+ # Trigger eviction by adding 'c'; eviction's scan exhausts our
1597+ # racing generator, the cleanup fires, then the eviction loop's
1598+ # re-stat sees the new stat and the production stat-guard MUST
1599+ # refuse to unlink path_a.
16271600 time .sleep (0.02 )
16281601 cache [b"c" ] = _fake_object_code (b"C" * 2000 , name = "c" )
16291602
1630- # The race-injected fresh file must survive (NOT unlinked by eviction) .
1603+ # The race-injected fresh file must survive: production stat-guard worked .
16311604 assert path_a .exists (), "stat guard failed -- evicted a concurrently-replaced file"
16321605 assert path_a .read_bytes ().startswith (b"\x80 \x05 fresh-by-other-writer-" )
16331606
0 commit comments