Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a04db4ee96 |
No files matched your search
@@ -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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+25
-16
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.6
|
||||
1.0.7
|
||||
Reference in new issue
Block a user