diff --git a/reme/steps/file_io/move.py b/reme/steps/file_io/move.py index e1bad6ba4..af74a2bec 100644 --- a/reme/steps/file_io/move.py +++ b/reme/steps/file_io/move.py @@ -29,6 +29,7 @@ original is still removed in that case (move semantics, not copy). """ +import asyncio import shutil from pathlib import Path @@ -82,7 +83,7 @@ async def _move(self, src_path: str, dst_path: str, overwrite: bool, retarget: b # Step 1 — copy. Wikilinks use workspace-relative paths, so outgoing # targets do not change when their containing document moves. - shutil.copyfile(str(src_abs), str(dst_abs)) + await asyncio.to_thread(shutil.copyfile, str(src_abs), str(dst_abs)) payload: dict = {"src_path": src_path, "dst_path": dst_path, "size": dst_abs.stat().st_size} # Step 2 — retarget. workspace_dir stays consistent throughout: refs still diff --git a/tests/unit/test_crud_steps.py b/tests/unit/test_crud_steps.py index 1132a686c..64fded3b7 100644 --- a/tests/unit/test_crud_steps.py +++ b/tests/unit/test_crud_steps.py @@ -29,6 +29,7 @@ import asyncio import os import tempfile +import threading import warnings from pathlib import Path, PureWindowsPath @@ -252,6 +253,44 @@ async def run(): asyncio.run(run()) +def test_move_copy_does_not_block_event_loop(monkeypatch): + """A slow file copy must not prevent unrelated async work from running.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + store = await _make_store({"daily/source.md": "draft"}) + step = crud_move.MoveStep(file_store=store) + copy_started = threading.Event() + release_copy = threading.Event() + original_copyfile = crud_move.shutil.copyfile + + def blocking_copyfile(src, dst): + copy_started.set() + assert release_copy.wait(timeout=1) + return original_copyfile(src, dst) + + monkeypatch.setattr(crud_move.shutil, "copyfile", blocking_copyfile) + release_timer = threading.Timer(0.2, release_copy.set) + release_timer.start() + try: + move_task = asyncio.create_task( + step(src_path="daily/source.md", dst_path="knowledge/source.md", retarget=False), + ) + await asyncio.sleep(0.02) + + assert copy_started.is_set() + assert not release_copy.is_set(), "synchronous copyfile blocked the event loop" + + release_copy.set() + await move_task + finally: + release_copy.set() + release_timer.join() + await store.close() + + asyncio.run(run()) + + def test_move_refuses_overwrite_without_flag(): """move refuses to clobber an existing dst_path unless overwrite=True."""