Skip to content

Commit cec90cb

Browse files
committed
fixup! test(core.utils): exercise real _enforce_size_cap via generator-cleanup race injection
1 parent c24b7cc commit cec90cb

1 file changed

Lines changed: 32 additions & 59 deletions

File tree

cuda_core/tests/test_program_cache.py

Lines changed: 32 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,84 +1550,57 @@ def test_filestream_cache_clear_does_not_break_concurrent_writer(tmp_path):
15501550

15511551

15521552
def 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\x05fresh-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\x05fresh-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\x05fresh-by-other-writer-")
16331606

0 commit comments

Comments
 (0)