From 2730832c3f6049c5bf50a93fada2d5185e4e99a1 Mon Sep 17 00:00:00 2001 From: yoruuuchan <1587761204@qq.com> Date: Thu, 6 Aug 2026 21:36:38 +0900 Subject: [PATCH] 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 --- skills/open-kimi-ppt/scripts/export_pptx.py | 21 ++++++++----- .../open-kimi-ppt/tests/test_export_pptx.py | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/skills/open-kimi-ppt/scripts/export_pptx.py b/skills/open-kimi-ppt/scripts/export_pptx.py index 92bd9ae..1bf8c1c 100755 --- a/skills/open-kimi-ppt/scripts/export_pptx.py +++ b/skills/open-kimi-ppt/scripts/export_pptx.py @@ -461,18 +461,23 @@ def find_download( last_sizes: Dict[Path, int] = {} stable: Dict[Path, int] = {} 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: if not root.exists(): continue - candidates.extend(path for path in root.rglob("*") if path.is_file()) - for path in sorted(candidates, key=lambda item: item.stat().st_mtime, reverse=True): - try: - stat = path.stat() - size = stat.st_size - if since is not None and stat.st_mtime < since: + for path in root.rglob("*"): + if not path.is_file(): 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 if size == last_sizes.get(path) and size > 0: stable[path] = stable.get(path, 0) + 1 diff --git a/skills/open-kimi-ppt/tests/test_export_pptx.py b/skills/open-kimi-ppt/tests/test_export_pptx.py index 24d5009..29640bb 100755 --- a/skills/open-kimi-ppt/tests/test_export_pptx.py +++ b/skills/open-kimi-ppt/tests/test_export_pptx.py @@ -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", + '' + '', + ) + archive.writestr("ppt/presentation.xml", "") + 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",