fix(scratch): preserve trees when idle scans are incomplete
This commit is contained in:
committed by
Teknium
parent
665bf373e7
commit
21453b3058
@@ -28,14 +28,15 @@ def subtree_touched_since(path: Path, cutoff: float) -> bool:
|
||||
Stops at the first recent entry, so a live tree costs one hit and only a truly idle
|
||||
tree pays for the full walk (once, right before it is deleted). Symlinks are never
|
||||
followed: a link into the repo would make the target's activity keep the entry alive.
|
||||
An unreadable entry is kept: an incomplete scan cannot establish that it is idle.
|
||||
"""
|
||||
try:
|
||||
if os.lstat(path).st_mtime >= cutoff:
|
||||
return True
|
||||
if not path.is_dir() or path.is_symlink():
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
if not path.is_dir() or path.is_symlink():
|
||||
return False
|
||||
return True
|
||||
stack = [str(path)]
|
||||
while stack:
|
||||
try:
|
||||
@@ -45,11 +46,11 @@ def subtree_touched_since(path: Path, cutoff: float) -> bool:
|
||||
if child.stat(follow_symlinks=False).st_mtime >= cutoff:
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return True
|
||||
if child.is_dir(follow_symlinks=False):
|
||||
stack.append(child.path)
|
||||
except OSError:
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
60
tests/test_scratch_scan_failures.py
Normal file
60
tests/test_scratch_scan_failures.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Cleanup needs a complete idle scan before deleting a scratch tree."""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_constants_scratch as scratch
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failed_probe", ["root-stat", "directory-scan", "child-stat"])
|
||||
def test_unreadable_subtree_is_preserved_until_it_can_be_scanned(tmp_path, monkeypatch, failed_probe):
|
||||
root = tmp_path / "scratch"
|
||||
entry = root / "lane"
|
||||
entry.mkdir(parents=True)
|
||||
result = entry / "result.txt"
|
||||
result.write_text("keep until activity can be checked", encoding="utf-8")
|
||||
old = time.time() - 48 * 3600
|
||||
os.utime(result, (old, old))
|
||||
os.utime(entry, (old, old))
|
||||
# Process inspection is unrelated to determining whether the files are idle.
|
||||
monkeypatch.setattr(scratch, "reap_processes_rooted_in", lambda *_: 0)
|
||||
real_lstat, real_scandir = os.lstat, os.scandir
|
||||
with monkeypatch.context() as probe:
|
||||
if failed_probe == "root-stat":
|
||||
def lstat(path, *args, **kwargs):
|
||||
if path == entry:
|
||||
raise PermissionError("cannot inspect entry")
|
||||
return real_lstat(path, *args, **kwargs)
|
||||
probe.setattr(scratch.os, "lstat", lstat)
|
||||
else:
|
||||
def scandir(path):
|
||||
if path == str(entry):
|
||||
if failed_probe == "directory-scan":
|
||||
raise PermissionError("cannot inspect subtree")
|
||||
|
||||
class UnreadableChild:
|
||||
name = "result.txt"
|
||||
|
||||
def stat(self, **_kwargs):
|
||||
raise OSError("cannot inspect child activity")
|
||||
|
||||
def is_dir(self, **_kwargs):
|
||||
return False
|
||||
|
||||
class Scan:
|
||||
def __enter__(self):
|
||||
return iter([UnreadableChild()])
|
||||
|
||||
def __exit__(self, *_args):
|
||||
pass
|
||||
|
||||
return Scan()
|
||||
return real_scandir(path)
|
||||
probe.setattr(scratch.os, "scandir", scandir)
|
||||
assert scratch.prune_idle_entries(root, 24, frozenset()) == 0
|
||||
assert result.read_text(encoding="utf-8") == "keep until activity can be checked"
|
||||
# Once observation succeeds, the same genuinely idle tree is reclaimable.
|
||||
assert scratch.prune_idle_entries(root, 24, frozenset()) == 1
|
||||
assert not entry.exists()
|
||||
Reference in New Issue
Block a user