diff --git a/CHANGELOG.md b/CHANGELOG.md index eff5e67..9a49f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Änderungsprotokoll +## 1.0.7 – 2026-09-24 + +### Behoben + +- Bei gesperrten Dateien, etwa in AppData, zeigt SyncVito eine kompakte Zusammenfassung und kennzeichnet die Sicherung als unvollständig. Vollständige Dateipfade und bekannte Sperrprozesse erscheinen im Protokoll. +- Fehlende Zugriffsrechte werden von tatsächlichen Dateisperren unterschieden; zusätzliche Fehler bleiben auch bei teilweise blockierten Sicherungen sichtbar. +- Die Sperrerkennung berücksichtigt die tatsächlich betroffene Quell- oder Zieldatei und verarbeitet Windows-Dateihandles und Fehlercodes korrekt. +- Automatische Wiederholungsversuche starten bereits nach Freigabe einzelner Dateien und berücksichtigen alle bereitstehenden Aufgaben. +- Während eines erneuten Synchronisationslaufs bleibt dessen Aktivität im Gesamtstatus sichtbar. + ## 1.0.6 – 2026-09-24 ### Behoben diff --git a/pyproject.toml b/pyproject.toml index 349bc1e..ad4d34c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "syncvito" -version = "1.0.6" +version = "1.0.7" description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation" readme = "README.md" requires-python = ">=3.11" diff --git a/src/syncvito/coordinator.py b/src/syncvito/coordinator.py index b70b55b..62ae363 100644 --- a/src/syncvito/coordinator.py +++ b/src/syncvito/coordinator.py @@ -25,7 +25,7 @@ def derive_overall_status( if TaskRuntimeState.ERROR in states: return "error", "Fehler" if TaskRuntimeState.WAITING_FOR_RELEASE in states: - return "error", "Wartet auf Dateifreigabe" + return "error", "Synchronisiert" if running else "Unvollständig – Dateien gesperrt" if globally_paused: return "paused", "Pausiert" if TaskRuntimeState.ACTIVE not in states: @@ -205,25 +205,31 @@ class SyncCoordinator(QObject): if result.blocked: paths = [blocked.path for blocked in result.blocked] self._retry_tasks[task_id] = paths - descriptions: list[str] = [] - for blocked in result.blocked: - names = ", ".join(f"{process.name} (PID {process.pid})" for process in blocked.processes) - descriptions.append(f"{blocked.path.name}: {names or 'sperrender Prozess nicht ermittelbar'}") - message = "Zurückgestellt – " + "; ".join(descriptions) + message = ( + f"{result.copied} kopiert, {len(result.blocked)} Dateien gesperrt – Sicherung unvollständig. " + "Andere Dateien wurden weiterverarbeitet. Automatischer Wiederholungsversuch nach Freigabe; " + "ggf. betroffene Programme schließen. Details im Protokoll." + ) self._lock_messages[task_id] = message self._emit_lock_summary() self.log_created.emit(task.name, "Blockiert", message) + for blocked in result.blocked: + names = ", ".join(f"{process.name} (PID {process.pid})" for process in blocked.processes) + self.log_created.emit( + task.name, "Blockiert", f"{blocked.path}: {names or 'sperrender Prozess nicht ermittelbar'}" + ) self._set_task_state(task_id, TaskRuntimeState.WAITING_FOR_RELEASE) - elif result.errors: - self._retry_tasks[task_id] = [] - self._lock_messages.pop(task_id, None) - self._emit_lock_summary() - self.log_created.emit(task.name, "Fehler", " | ".join(result.errors[:5])) - self._set_task_state(task_id, TaskRuntimeState.ERROR) else: - self._retry_tasks.pop(task_id, None) self._lock_messages.pop(task_id, None) self._emit_lock_summary() + if result.errors: + # Auch zusätzliche Fehler eines teilweise blockierten Laufs sichtbar halten. + self._retry_tasks[task_id] = [] + for error in result.errors: + self.log_created.emit(task.name, "Fehler", error) + self._set_task_state(task_id, TaskRuntimeState.ERROR) + elif not result.blocked: + self._retry_tasks.pop(task_id, None) summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien" self.log_created.emit(task.name, "Erfolg", summary) self._set_task_state(task_id, TaskRuntimeState.ACTIVE) @@ -273,13 +279,12 @@ class SyncCoordinator(QObject): if self.config.settings.paused or self.is_running: return for task_id, paths in list(self._retry_tasks.items()): - if paths and any(is_file_locked(path) for path in paths): + if paths and all(is_file_locked(path) for path in paths): continue self.log_created.emit( "Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft." ) self.enqueue(task_id) - break def _task(self, task_id: str) -> SyncTask | None: return next((task for task in self.config.tasks if task.id == task_id), None) @@ -296,4 +301,8 @@ class SyncCoordinator(QObject): self.overall_status_changed.emit(status, text) def _emit_lock_summary(self) -> None: - self.lock_changed.emit("\n".join(self._lock_messages.values())) + messages = list(self._lock_messages.values()) + summary = "\n".join(messages[:2]) + if len(messages) > 2: + summary += f"\nWeitere {len(messages) - 2} Aufgaben mit gesperrten Dateien. Details im Protokoll." + self.lock_changed.emit(summary) diff --git a/src/syncvito/file_locks.py b/src/syncvito/file_locks.py index 7a1955a..829bd27 100644 --- a/src/syncvito/file_locks.py +++ b/src/syncvito/file_locks.py @@ -23,7 +23,22 @@ def is_file_locked(path: Path) -> bool: """Prüft eine vorhandene Datei durch exklusives Öffnen ohne Schreibzugriff.""" if not path.exists() or os.name != "nt": return False - handle = ctypes.windll.kernel32.CreateFileW( + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + create_file.restype = wintypes.HANDLE + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + close_handle.restype = wintypes.BOOL + handle = create_file( str(path), 0x80000000, 0, @@ -34,8 +49,8 @@ def is_file_locked(path: Path) -> bool: ) invalid_handle = ctypes.c_void_p(-1).value if handle == invalid_handle: - return ctypes.get_last_error() in {5, 32, 33} - ctypes.windll.kernel32.CloseHandle(handle) + return ctypes.get_last_error() in {32, 33} + close_handle(handle) return False diff --git a/src/syncvito/sync_engine.py b/src/syncvito/sync_engine.py index 7a917d1..cb87243 100644 --- a/src/syncvito/sync_engine.py +++ b/src/syncvito/sync_engine.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from syncvito.file_locks import LockingProcess, find_locking_processes +from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask from syncvito.state_store import StateStore @@ -117,12 +117,20 @@ class SyncEngine: for index, operation in enumerate(operations, start=1): try: self._execute(operation, result) - except PermissionError as exc: - processes = tuple(find_locking_processes(operation.target)) - result.blocked.append(BlockedFile(operation.target, processes)) - result.errors.append(f"Zugriff verweigert: {operation.target} ({exc})") except (OSError, shutil.Error) as exc: - result.errors.append(f"{operation.target}: {exc}") + failed_path = ( + Path(exc.filename2 or exc.filename) + if isinstance(exc, OSError) and (exc.filename2 or exc.filename) + else operation.target + ) + blocked_path = _blocked_path(operation, exc) + if blocked_path is not None: + processes = tuple(find_locking_processes(blocked_path)) + result.blocked.append(BlockedFile(blocked_path, processes)) + elif isinstance(exc, PermissionError): + result.errors.append(f"Zugriff verweigert: {failed_path} ({exc})") + else: + result.errors.append(f"{failed_path}: {exc}") if progress: detail = f"{operation.kind}: {operation.relative}" progress(round(index / total * 100), detail) @@ -295,6 +303,25 @@ class SyncEngine: } +def _blocked_path(operation: _Operation, exc: OSError | shutil.Error) -> Path | None: + """Ordnet auch Windows-Kopierfehler ohne Dateinamen der gesperrten Seite zu.""" + winerror = getattr(exc, "winerror", None) + if winerror not in {5, 32, 33}: + return None + reported = (exc.filename2 or exc.filename) if isinstance(exc, OSError) else None + if reported and winerror in {32, 33}: + return Path(reported) + candidates = [Path(reported)] if reported else [] + candidates.extend(path for path in (operation.source, operation.target) if path is not None) + for path in dict.fromkeys(candidates): + if is_file_locked(path): + return path + # Eine inzwischen freigegebene Datei beim nächsten Lauf erneut versuchen. + if winerror in {32, 33}: + return operation.source or operation.target + return None + + def _atomic_copy(source: Path, target: Path) -> None: temporary = target.with_name(f".{target.name}.syncvito.tmp") try: diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..9df09f0 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,75 @@ +from pathlib import Path + +import pytest +from PyQt6.QtWidgets import QApplication + +from syncvito.config_store import ConfigStore +from syncvito.coordinator import SyncCoordinator +from syncvito.models import AppConfig, SyncTask, TaskRuntimeState +from syncvito.sync_engine import BlockedFile, SyncResult + + +@pytest.fixture +def coordinator(tmp_path: Path): + application = QApplication.instance() or QApplication([]) + task = SyncTask(name="AppData", source=str(tmp_path / "source"), target=str(tmp_path / "target")) + coordinator = SyncCoordinator(AppConfig(tasks=[task]), ConfigStore(tmp_path / "config.json")) + yield coordinator + coordinator._schedule_timer.stop() + coordinator._retry_timer.stop() + coordinator.stop() + assert application is not None + + +def test_many_locks_have_compact_banner_and_full_paths_in_log(coordinator: SyncCoordinator) -> None: + banners = [] + logs = [] + coordinator.lock_changed.connect(banners.append) + coordinator.log_created.connect(lambda *entry: logs.append(entry)) + blocked = [BlockedFile(Path(f"AppData/Programm{index}/Cookies")) for index in range(300)] + task = coordinator.config.tasks[0] + + coordinator._on_finished(task.id, SyncResult(copied=123, blocked=blocked)) + + assert len(banners[-1]) < 400 + assert "123 kopiert" in banners[-1] + assert "300 Dateien gesperrt" in banners[-1] + assert "unvollständig" in banners[-1] + assert len(logs) == 301 + assert str(blocked[-1].path) in logs[-1][2] + assert coordinator.task_state(task.id) is TaskRuntimeState.WAITING_FOR_RELEASE + + coordinator._on_finished(task.id, SyncResult(copied=300)) + assert banners[-1] == "" + assert task.id not in coordinator._retry_tasks + assert coordinator.task_state(task.id) is TaskRuntimeState.ACTIVE + + +def test_errors_are_visible_even_when_other_files_are_locked(coordinator: SyncCoordinator) -> None: + logs = [] + coordinator.log_created.connect(lambda *entry: logs.append(entry)) + task = coordinator.config.tasks[0] + coordinator._on_finished(task.id, SyncResult(blocked=[BlockedFile(Path("Cookies"))], errors=["Zugriff verweigert"])) + assert coordinator.task_state(task.id) is TaskRuntimeState.ERROR + assert any(level == "Fehler" and detail == "Zugriff verweigert" for _, level, detail in logs) + + +@pytest.mark.parametrize("all_locked", [False, True]) +def test_retry_can_run_after_partial_release( + coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch, all_locked: bool +) -> None: + task = coordinator.config.tasks[0] + coordinator._retry_tasks[task.id] = [Path("dauerhaft"), Path("freigegeben")] + queued = [] + monkeypatch.setattr(coordinator, "enqueue", queued.append) + monkeypatch.setattr("syncvito.coordinator.is_file_locked", lambda path: all_locked or path.name == "dauerhaft") + coordinator._check_retries() + assert queued == ([] if all_locked else [task.id]) + + +def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch) -> None: + coordinator._retry_tasks = {"first": [], "second": []} + queued = [] + monkeypatch.setattr(coordinator, "enqueue", queued.append) + coordinator._check_retries() + assert queued == ["first", "second"] diff --git a/tests/test_file_locks.py b/tests/test_file_locks.py new file mode 100644 index 0000000..e0c85ec --- /dev/null +++ b/tests/test_file_locks.py @@ -0,0 +1,74 @@ +import ctypes +import os +from contextlib import contextmanager +from ctypes import wintypes +from pathlib import Path + +import pytest + +from syncvito.file_locks import is_file_locked +from syncvito.models import SyncTask +from syncvito.state_store import StateStore +from syncvito.sync_engine import SyncEngine + +pytestmark = pytest.mark.skipif(os.name != "nt", reason="Windows-Dateisperren") + + +@contextmanager +def exclusive_lock(path: Path): + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + kernel32.CreateFileW.restype = wintypes.HANDLE + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + handle = kernel32.CreateFileW(str(path), 0x80000000, 0, None, 3, 0x80, None) + assert handle != ctypes.c_void_p(-1).value, ctypes.get_last_error() + try: + yield + finally: + assert kernel32.CloseHandle(handle) + + +def test_detects_real_lock_and_release(tmp_path: Path) -> None: + path = tmp_path / "gesperrt.txt" + path.write_text("Inhalt") + assert not is_file_locked(path) + with exclusive_lock(path): + assert is_file_locked(path) + assert not is_file_locked(path) + assert not is_file_locked(tmp_path / "fehlt.txt") + + +@pytest.mark.parametrize("locked_side", ["source", "target"]) +def test_sync_preserves_backup_and_continues_past_lock(tmp_path: Path, locked_side: str) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + target.mkdir() + (source / "a.txt").write_text("neue Version") + (target / "a.txt").write_text("alt") + (source / "b.txt").write_text("lesbar") + locked_path = tmp_path / locked_side / "a.txt" + task = SyncTask(name="AppData", source=str(source), target=str(target)) + engine = SyncEngine(StateStore(tmp_path / "state.json")) + + with exclusive_lock(locked_path): + result = engine.synchronize(task) + assert not result.successful + assert [item.path for item in result.blocked] == [locked_path] + assert not result.errors + assert result.copied == 1 + assert (target / "b.txt").read_text() == "lesbar" + + assert (target / "a.txt").read_text() == "alt" + assert not list(target.glob("*.tmp")) + assert engine.synchronize(task).successful + assert (target / "a.txt").read_text() == "neue Version" diff --git a/tests/test_runtime_status.py b/tests/test_runtime_status.py index 651e96b..7b66bf4 100644 --- a/tests/test_runtime_status.py +++ b/tests/test_runtime_status.py @@ -14,7 +14,14 @@ def test_healthy_active_tasks_are_green() -> None: def test_waiting_lock_has_priority_over_active_task() -> None: states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.WAITING_FOR_RELEASE] - assert derive_overall_status(states, globally_paused=False) == ("error", "Wartet auf Dateifreigabe") + assert derive_overall_status(states, globally_paused=False) == ("error", "Unvollständig – Dateien gesperrt") + + +def test_waiting_task_does_not_hide_running_sync() -> None: + assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == ( + "error", + "Synchronisiert", + ) def test_error_has_highest_priority() -> None: diff --git a/tests/test_sync_engine.py b/tests/test_sync_engine.py index e65eb67..960025f 100644 --- a/tests/test_sync_engine.py +++ b/tests/test_sync_engine.py @@ -1,6 +1,8 @@ import os from pathlib import Path +import pytest + from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask from syncvito.state_store import StateStore from syncvito.sync_engine import SyncEngine @@ -10,6 +12,37 @@ def _engine(tmp_path: Path) -> SyncEngine: return SyncEngine(StateStore(tmp_path / "state.json")) +@pytest.mark.parametrize("winerror", [None, 5]) +def test_access_denied_is_not_reported_as_file_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror: int | None +) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + (source / "geschuetzt.txt").write_text("geschützt") + (source / "lesbar.txt").write_text("lesbar") + from syncvito import sync_engine + + copy = sync_engine._atomic_copy + + def copy_with_denied_file(source_path: Path, target_path: Path) -> None: + if source_path.name == "geschuetzt.txt": + error = PermissionError(13, "Zugriff verweigert", str(source_path)) + if winerror is not None: + error.winerror = winerror + raise error + copy(source_path, target_path) + + monkeypatch.setattr(sync_engine, "_atomic_copy", copy_with_denied_file) + result = _engine(tmp_path).synchronize(SyncTask(name="AppData", source=str(source), target=str(target))) + + assert not result.successful + assert not result.blocked + assert len(result.errors) == 1 + assert str(source / "geschuetzt.txt") in result.errors[0] + assert (target / "lesbar.txt").read_text() == "lesbar" + + def test_one_way_copy_and_mirror_delete(tmp_path: Path) -> None: source = tmp_path / "source" target = tmp_path / "target" diff --git a/version.txt b/version.txt index af0b7dd..238d6e8 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.6 +1.0.7