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:
yoruuuchan
2026-08-06 21:36:38 +09:00
co-authored by Claude Fable 5
parent f77ee8dfcb
commit 2730832c3f
2 changed files with 44 additions and 8 deletions
@@ -163,6 +163,37 @@ class ExportPptxTests(unittest.TestCase):
found = MODULE.find_download([root], timeout=2.0, since=since)
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):
session = MODULE.BrowserSession(
"/bin/agent-browser",