diff --git a/shelfmark/download/fs.py b/shelfmark/download/fs.py index cfe1eb76..7e3b987e 100644 --- a/shelfmark/download/fs.py +++ b/shelfmark/download/fs.py @@ -229,6 +229,10 @@ def _is_permission_error(e: Exception) -> bool: return isinstance(e, PermissionError) or (isinstance(e, OSError) and e.errno == errno.EPERM) +def _should_fallback_to_content_copy(error: Exception) -> bool: + return _is_permission_error(error) or (isinstance(error, OSError) and error.errno == errno.EIO) + + def _system_op(op: str, source: Path, dest: Path) -> None: """Execute system command (mv or cp) as final fallback.""" logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest) @@ -463,9 +467,9 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> try: run_blocking_io(shutil.copy2, str(source_path), str(temp_path)) except (PermissionError, OSError) as copy_error: - if _is_permission_error(copy_error): + if _should_fallback_to_content_copy(copy_error): logger.debug( - "Permission error during move-copy, falling back to copyfile (%s -> %s): %s", + "copy2 failed during move-copy, falling back to copyfile (%s -> %s): %s", source_path, temp_path, copy_error, @@ -631,16 +635,16 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> try: run_blocking_io(shutil.copy2, str(source_path), str(temp_path)) except (PermissionError, OSError) as e: - # Handle NFS permission errors immediately here - if _is_permission_error(e): - log_transfer_permission_context( - "atomic_copy", - source=source_path, - dest=temp_path, - error=e, - ) + if _should_fallback_to_content_copy(e): + if _is_permission_error(e): + log_transfer_permission_context( + "atomic_copy", + source=source_path, + dest=temp_path, + error=e, + ) logger.debug( - "Permission error during copy, falling back to copyfile (%s -> %s): %s", + "copy2 failed during copy, falling back to copyfile (%s -> %s): %s", source_path, temp_path, e, diff --git a/tests/core/test_download_processing.py b/tests/core/test_download_processing.py index 6836fa25..7342de1d 100644 --- a/tests/core/test_download_processing.py +++ b/tests/core/test_download_processing.py @@ -240,6 +240,26 @@ class TestAtomicCopy: assert result.exists() assert result.read_text() == "content" + def test_copy_falls_back_when_copy2_hits_fuse_eio(self, tmp_path): + """Fall back to content copy when FUSE rejects xattr metadata reads.""" + import errno + + from shelfmark.download.fs import atomic_copy as _atomic_copy + + source = tmp_path / "source.txt" + source.write_text("content") + dest = tmp_path / "dest.txt" + + with patch( + "shelfmark.download.fs.shutil.copy2", + side_effect=OSError(errno.EIO, "Input/output error"), + ): + result = _atomic_copy(source, dest) + + assert result == dest + assert result.exists() + assert result.read_text() == "content" + def test_copy_tolerates_post_publish_estale(self, tmp_path, monkeypatch): """Treat ESTALE on the final destination as a successful NFS publish.""" import errno diff --git a/tests/core/test_hardlink.py b/tests/core/test_hardlink.py index cc58571c..8dc0f614 100644 --- a/tests/core/test_hardlink.py +++ b/tests/core/test_hardlink.py @@ -407,6 +407,32 @@ class TestAtomicMove: assert mock_copy.called assert mock_fallback.called + def test_cross_filesystem_move_falls_back_when_copy2_hits_fuse_eio(self, tmp_path, monkeypatch): + """Falls back to content copy when FUSE rejects xattr metadata reads.""" + import errno + + from shelfmark.download.fs import atomic_move as _atomic_move + + source = tmp_path / "source.txt" + source.write_text("content") + dest = tmp_path / "dest.txt" + + def _raise_exdev(*_args, **_kwargs): + raise OSError(errno.EXDEV, "Cross-device link") + + monkeypatch.setattr(os, "rename", _raise_exdev) + + with patch( + "shelfmark.download.fs.shutil.copy2", + side_effect=OSError(errno.EIO, "Input/output error"), + ): + result = _atomic_move(source, dest) + + assert result == dest + assert not source.exists() + assert dest.exists() + assert dest.read_text() == "content" + def test_cross_filesystem_move_recovers_when_metadata_step_hits_enoent( self, tmp_path, monkeypatch ):