fix: tolerate files vanishing mid-scan in find_download
The sort key in find_download() called item.stat() unprotected. The search roots include the live Downloads folder, where Chrome renames "*.crdownload" files to their final name between directory listing and stat(), crashing the whole export with FileNotFoundError. Stat once while collecting (skipping entries that raise OSError), then sort the snapshot. This also halves the stat() calls per polling round. Fixes the remaining race reported in #4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f77ee8dfcb
commit
2730832c3f
@@ -461,18 +461,23 @@ def find_download(
|
|||||||
last_sizes: Dict[Path, int] = {}
|
last_sizes: Dict[Path, int] = {}
|
||||||
stable: Dict[Path, int] = {}
|
stable: Dict[Path, int] = {}
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
candidates: List[Path] = []
|
# Snapshot stats while collecting and tolerate races everywhere: the
|
||||||
|
# search roots include the live Downloads folder, where Chrome renames
|
||||||
|
# .crdownload files away between directory listing and stat().
|
||||||
|
entries: List[Tuple[Path, float, int]] = []
|
||||||
for root in search_roots:
|
for root in search_roots:
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
continue
|
continue
|
||||||
candidates.extend(path for path in root.rglob("*") if path.is_file())
|
for path in root.rglob("*"):
|
||||||
for path in sorted(candidates, key=lambda item: item.stat().st_mtime, reverse=True):
|
if not path.is_file():
|
||||||
try:
|
|
||||||
stat = path.stat()
|
|
||||||
size = stat.st_size
|
|
||||||
if since is not None and stat.st_mtime < since:
|
|
||||||
continue
|
continue
|
||||||
except OSError:
|
try:
|
||||||
|
info = path.stat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
entries.append((path, info.st_mtime, info.st_size))
|
||||||
|
for path, mtime, size in sorted(entries, key=lambda entry: entry[1], reverse=True):
|
||||||
|
if since is not None and mtime < since:
|
||||||
continue
|
continue
|
||||||
if size == last_sizes.get(path) and size > 0:
|
if size == last_sizes.get(path) and size > 0:
|
||||||
stable[path] = stable.get(path, 0) + 1
|
stable[path] = stable.get(path, 0) + 1
|
||||||
|
|||||||
@@ -163,6 +163,37 @@ class ExportPptxTests(unittest.TestCase):
|
|||||||
found = MODULE.find_download([root], timeout=2.0, since=since)
|
found = MODULE.find_download([root], timeout=2.0, since=since)
|
||||||
self.assertEqual(found.resolve(), new.resolve())
|
self.assertEqual(found.resolve(), new.resolve())
|
||||||
|
|
||||||
|
def test_find_download_survives_files_vanishing_mid_scan(self):
|
||||||
|
# Chrome renames "*.crdownload" files away between directory listing
|
||||||
|
# and stat(); a vanished file must be skipped, not crash the export.
|
||||||
|
with tempfile.TemporaryDirectory() as name:
|
||||||
|
root = Path(name)
|
||||||
|
deck = root / "deck.pptx"
|
||||||
|
with zipfile.ZipFile(deck, "w") as archive:
|
||||||
|
archive.writestr(
|
||||||
|
"[Content_Types].xml",
|
||||||
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||||
|
'<Override PartName="/ppt/presentation.xml" '
|
||||||
|
f'ContentType="{MODULE.PPTX_CONTENT_TYPE}"/></Types>',
|
||||||
|
)
|
||||||
|
archive.writestr("ppt/presentation.xml", "<p:presentation/>")
|
||||||
|
ghost = root / "ghost.crdownload"
|
||||||
|
ghost.write_bytes(b"partial download")
|
||||||
|
|
||||||
|
real_stat = Path.stat
|
||||||
|
seen = {"count": 0}
|
||||||
|
|
||||||
|
def racy_stat(self, **kwargs):
|
||||||
|
if self.name == "ghost.crdownload":
|
||||||
|
seen["count"] += 1
|
||||||
|
if seen["count"] > 1:
|
||||||
|
raise FileNotFoundError(2, "vanished mid-scan", str(self))
|
||||||
|
return real_stat(self, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(Path, "stat", racy_stat):
|
||||||
|
found = MODULE.find_download([root], timeout=2.0)
|
||||||
|
self.assertEqual(found.resolve(), deck.resolve())
|
||||||
|
|
||||||
def test_browser_open_does_not_pass_download_path(self):
|
def test_browser_open_does_not_pass_download_path(self):
|
||||||
session = MODULE.BrowserSession(
|
session = MODULE.BrowserSession(
|
||||||
"/bin/agent-browser",
|
"/bin/agent-browser",
|
||||||
|
|||||||
Reference in New Issue
Block a user