Release SyncVito 1.0.12

This commit is contained in:
Davito committed 2026-09-27 23:46:11 +02:00
1 parent ccb89191b5
commit 786e3ae9b3
8 files changed
+144 -8

No files matched your search

+9
View File
@@ -1,5 +1,14 @@
# Änderungsprotokoll # Änderungsprotokoll
## 1.0.12 – 2026-09-27
### Behoben
- **Endlosschleife bei Wiederholungen beseitigt:** Aufgaben in Wiederholungsschleifen werden nach dem Einreihen sauber aus der Retry-Warteliste entfernt und bei reinen Fehlern ohne gesperrte Dateien nicht mehr dauerhaft im 10-Sekunden-Takt neu gestartet.
- **Wiederholungs-Backoff und Versuchslimit:** Für gesperrte Dateien greift ein exponentielles Backoff (30s, 60s, 120s bis max. 300s) sowie ein Limit von 5 automatischen Versuchen, um Festplatte, Netzwerk und Prozessor vor unnötigen Dauerläufen zu schützen.
- **Flüchtige Cache-Dateien ohne Fehlermeldung:** Dateien, die zwischen Inventarerstellung und Kopiervorgang gelöscht wurden (z. B. dynamische Browser- oder Anwendungs-Caches), werden sauber als übersprungen gewertet und brechen die Sicherung nicht mehr mit `[WinError 2]` ab.
- **Kein Einfrieren der Benutzeroberfläche:** Das automatische Weiterscrollen der Protokolltabelle bei vielen gleichzeitigen Meldungen wird gebündelt entprellt, die Abfrage des Windows Restart Managers für gesperrte Ordner wird pro Verzeichnis zwischengespeichert und Fortschrittsmeldungen werden im GUI-Thread gedrosselt.
## 1.0.11 – 2026-09-27 ## 1.0.11 – 2026-09-27
### Behoben ### Behoben
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "syncvito" name = "syncvito"
version = "1.0.11" version = "1.0.12"
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation" description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+48 -4
View File
@@ -88,6 +88,8 @@ class SyncCoordinator(QObject):
self._queue: deque[str] = deque() self._queue: deque[str] = deque()
self._running_task_id: str | None = None self._running_task_id: str | None = None
self._retry_tasks: dict[str, list[Path]] = {} self._retry_tasks: dict[str, list[Path]] = {}
self._retry_attempts: dict[str, int] = {}
self._retry_cooldowns: dict[str, datetime] = {}
self._lock_messages: dict[str, str] = {} self._lock_messages: dict[str, str] = {}
self._task_states: dict[str, TaskRuntimeState] = { self._task_states: dict[str, TaskRuntimeState] = {
task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks
@@ -142,6 +144,8 @@ class SyncCoordinator(QObject):
known_ids = {task.id for task in self.config.tasks} known_ids = {task.id for task in self.config.tasks}
self._task_states = {task_id: state for task_id, state in self._task_states.items() if task_id in known_ids} self._task_states = {task_id: state for task_id, state in self._task_states.items() if task_id in known_ids}
self._retry_tasks = {task_id: paths for task_id, paths in self._retry_tasks.items() if task_id in known_ids} self._retry_tasks = {task_id: paths for task_id, paths in self._retry_tasks.items() if task_id in known_ids}
self._retry_attempts = {task_id: att for task_id, att in self._retry_attempts.items() if task_id in known_ids}
self._retry_cooldowns = {task_id: cd for task_id, cd in self._retry_cooldowns.items() if task_id in known_ids}
self._lock_messages = { self._lock_messages = {
task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids
} }
@@ -152,6 +156,8 @@ class SyncCoordinator(QObject):
self._set_task_state(task.id, desired) self._set_task_state(task.id, desired)
if not task.enabled: if not task.enabled:
self._retry_tasks.pop(task.id, None) self._retry_tasks.pop(task.id, None)
self._retry_attempts.pop(task.id, None)
self._retry_cooldowns.pop(task.id, None)
self._lock_messages.pop(task.id, None) self._lock_messages.pop(task.id, None)
enabled_ids = {task.id for task in self.config.tasks if task.enabled} enabled_ids = {task.id for task in self.config.tasks if task.enabled}
self._queue = deque(task_id for task_id in self._queue if task_id in enabled_ids) self._queue = deque(task_id for task_id in self._queue if task_id in enabled_ids)
@@ -179,6 +185,8 @@ class SyncCoordinator(QObject):
task = self._task(task_id) task = self._task(task_id)
if task is None or not task.enabled: if task is None or not task.enabled:
return return
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
if self.config.settings.paused: if self.config.settings.paused:
if task_id != self._running_task_id and task_id not in self._queue: if task_id != self._running_task_id and task_id not in self._queue:
self._queue.append(task_id) self._queue.append(task_id)
@@ -229,7 +237,9 @@ class SyncCoordinator(QObject):
"""Aktualisiert alle Dateisystemüberwachungen.""" """Aktualisiert alle Dateisystemüberwachungen."""
errors = self.monitor.rebuild(self.config.tasks) errors = self.monitor.rebuild(self.config.tasks)
for task_id, message in errors: for task_id, message in errors:
self._retry_tasks[task_id] = [] self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
self._set_task_state(task_id, TaskRuntimeState.ERROR) self._set_task_state(task_id, TaskRuntimeState.ERROR)
self.log_created.emit("Echtzeit", "Fehler", message) self.log_created.emit("Echtzeit", "Fehler", message)
self._emit_overall_status() self._emit_overall_status()
@@ -315,12 +325,17 @@ class SyncCoordinator(QObject):
self._emit_lock_summary() self._emit_lock_summary()
if result.errors: if result.errors:
# Auch zusätzliche Fehler eines teilweise blockierten Laufs sichtbar halten. # Auch zusätzliche Fehler eines teilweise blockierten Laufs sichtbar halten.
self._retry_tasks[task_id] = [] if not result.blocked:
self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
for error in result.errors: for error in result.errors:
self.log_created.emit(task.name, "Fehler", error) self.log_created.emit(task.name, "Fehler", error)
self._set_task_state(task_id, TaskRuntimeState.ERROR) self._set_task_state(task_id, TaskRuntimeState.ERROR)
elif not result.blocked: elif not result.blocked:
self._retry_tasks.pop(task_id, None) self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien" summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien"
self.log_created.emit(task.name, "Erfolg", summary) self.log_created.emit(task.name, "Erfolg", summary)
self._set_task_state(task_id, TaskRuntimeState.ACTIVE) self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
@@ -329,7 +344,9 @@ class SyncCoordinator(QObject):
def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None: def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None:
task = self._task(task_id) task = self._task(task_id)
name = task.name if task else "Aufgabe" name = task.name if task else "Aufgabe"
self._retry_tasks[task_id] = [] self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
self._lock_messages.pop(task_id, None) self._lock_messages.pop(task_id, None)
self._emit_lock_summary() self._emit_lock_summary()
self._set_task_state(task_id, TaskRuntimeState.ERROR) self._set_task_state(task_id, TaskRuntimeState.ERROR)
@@ -372,9 +389,36 @@ class SyncCoordinator(QObject):
def _check_retries(self) -> None: def _check_retries(self) -> None:
if self.config.settings.paused or self.is_running: if self.config.settings.paused or self.is_running:
return return
now = datetime.now()
ready_tasks: list[str] = []
for task_id, paths in list(self._retry_tasks.items()): for task_id, paths in list(self._retry_tasks.items()):
if paths and all(is_file_locked(path) for path in paths): cooldown_until = self._retry_cooldowns.get(task_id)
if cooldown_until and now < cooldown_until:
continue continue
attempts = self._retry_attempts.get(task_id, 0)
if attempts >= 5:
if attempts == 5:
task = self._task(task_id)
name = task.name if task else task_id
self.log_created.emit(
name,
"Hinweis",
"Automatische Wiederholungen vorübergehend ausgesetzt. Bitte sperrende Programme schließen.",
)
self._retry_attempts[task_id] = attempts + 1
continue
check_paths = paths[:10] if len(paths) > 10 else paths
if paths and all(is_file_locked(path) for path in check_paths):
continue
ready_tasks.append(task_id)
for task_id in ready_tasks:
self._retry_tasks.pop(task_id, None)
attempts = self._retry_attempts.get(task_id, 0) + 1
self._retry_attempts[task_id] = attempts
backoff_seconds = min(300, 30 * (2 ** min(attempts - 1, 4)))
self._retry_cooldowns[task_id] = now + timedelta(seconds=backoff_seconds)
self.log_created.emit( self.log_created.emit(
"Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft." "Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft."
) )
+9
View File
@@ -803,6 +803,15 @@ class MainWindow(QMainWindow):
self.log_table.setItem(row, column, item) self.log_table.setItem(row, column, item)
if self.log_table.rowCount() > 1000: if self.log_table.rowCount() > 1000:
self.log_table.removeRow(0) self.log_table.removeRow(0)
self._schedule_log_scroll()
def _schedule_log_scroll(self) -> None:
if not getattr(self, "_log_scroll_pending", False):
self._log_scroll_pending = True
QTimer.singleShot(50, self._do_scroll_log)
def _do_scroll_log(self) -> None:
self._log_scroll_pending = False
self.log_table.scrollToBottom() self.log_table.scrollToBottom()
def _set_global_status(self, status: str, text: str) -> None: def _set_global_status(self, status: str, text: str) -> None:
+28 -2
View File
@@ -141,6 +141,11 @@ class SyncEngine:
if stage_name not in stages: if stage_name not in stages:
stages.append(stage_name) stages.append(stage_name)
known_locking_processes: dict[Path, tuple[LockingProcess, ...]] = {}
rm_query_count = 0
last_progress_time = 0.0
current_stage = ""
if progress: if progress:
progress(0, "Änderungen werden vorbereitet …") progress(0, "Änderungen werden vorbereitet …")
for index, operation in enumerate(operations, start=1): for index, operation in enumerate(operations, start=1):
@@ -154,6 +159,9 @@ class SyncEngine:
try: try:
self._execute(operation, result) self._execute(operation, result)
except (OSError, shutil.Error) as exc: except (OSError, shutil.Error) as exc:
if isinstance(exc, FileNotFoundError) and operation.source and not operation.source.exists():
result.skipped += 1
continue
failed_path = ( failed_path = (
Path(exc.filename2 or exc.filename) Path(exc.filename2 or exc.filename)
if isinstance(exc, OSError) and (exc.filename2 or exc.filename) if isinstance(exc, OSError) and (exc.filename2 or exc.filename)
@@ -161,13 +169,28 @@ class SyncEngine:
) )
blocked_path = _blocked_path(operation, exc) blocked_path = _blocked_path(operation, exc)
if blocked_path is not None: if blocked_path is not None:
processes = tuple(find_locking_processes(blocked_path)) parent_dir = blocked_path.parent
if parent_dir in known_locking_processes:
processes = known_locking_processes[parent_dir]
elif rm_query_count < 10:
discovered = tuple(find_locking_processes(blocked_path))
rm_query_count += 1
if discovered:
known_locking_processes[parent_dir] = discovered
processes = discovered
else:
processes = next(iter(known_locking_processes.values()), ())
result.blocked.append(BlockedFile(blocked_path, processes)) result.blocked.append(BlockedFile(blocked_path, processes))
elif isinstance(exc, PermissionError): elif isinstance(exc, PermissionError):
result.errors.append(f"Zugriff verweigert: {failed_path} ({exc})") result.errors.append(f"Zugriff verweigert: {failed_path} ({exc})")
else: else:
result.errors.append(f"{failed_path}: {exc}") result.errors.append(f"{failed_path}: {exc}")
if progress: now = monotonic()
is_last = index == total
stage_changed = stage != current_stage
if progress and (is_last or stage_changed or now - last_progress_time >= 0.05):
last_progress_time = now
current_stage = stage
if len(stages) > 1: if len(stages) > 1:
stage_idx = stages.index(stage) + 1 stage_idx = stages.index(stage) + 1
detail = f"[{stage_idx}/{len(stages)} {stage}] {operation.kind}: {operation.relative}" detail = f"[{stage_idx}/{len(stages)} {stage}] {operation.kind}: {operation.relative}"
@@ -344,6 +367,9 @@ class SyncEngine:
return return
if operation.source is None: if operation.source is None:
return return
if not operation.source.exists():
result.skipped += 1
return
operation.target.parent.mkdir(parents=True, exist_ok=True) operation.target.parent.mkdir(parents=True, exist_ok=True)
if operation.conflict_copy and operation.target.exists(): if operation.conflict_copy and operation.target.exists():
conflict_path = _conflict_path(operation.target) conflict_path = _conflict_path(operation.target)
+24
View File
@@ -73,6 +73,30 @@ def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch:
monkeypatch.setattr(coordinator, "enqueue", queued.append) monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries() coordinator._check_retries()
assert queued == ["first", "second"] assert queued == ["first", "second"]
assert "first" not in coordinator._retry_tasks
assert "second" not in coordinator._retry_tasks
def test_task_with_only_errors_is_not_queued_for_auto_retry(
coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch
) -> None:
task = coordinator.config.tasks[0]
coordinator._on_finished(task.id, SyncResult(errors=["Zugriff verweigert"]))
assert task.id not in coordinator._retry_tasks
queued = []
monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries()
assert queued == []
def test_retry_max_attempts_cap(coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch) -> None:
task = coordinator.config.tasks[0]
coordinator._retry_tasks[task.id] = []
coordinator._retry_attempts[task.id] = 5
queued = []
monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries()
assert queued == []
def test_start_resets_stale_progress_before_worker_starts( def test_start_resets_stale_progress_before_worker_starts(
+24
View File
@@ -269,3 +269,27 @@ def test_pause_and_resume_continues_where_interrupted(tmp_path: Path) -> None:
assert (target / "a.txt").exists() assert (target / "a.txt").exists()
assert (target / "b.txt").exists() assert (target / "b.txt").exists()
assert (target / "c.txt").exists() assert (target / "c.txt").exists()
def test_concurrently_deleted_source_file_is_skipped(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
target.mkdir()
(source / "ephemeral.txt").write_text("wird gelöscht")
task = SyncTask(name="Transient", source=str(source), target=str(target))
engine = _engine(tmp_path)
# Simuliere, dass die Datei kurz vor der Ausführung gelöscht wurde
def delete_before_execute(operation, result):
if operation.source and operation.source.name == "ephemeral.txt":
operation.source.unlink(missing_ok=True)
return SyncEngine._execute(operation, result)
monkeypatch.setattr(engine, "_execute", delete_before_execute)
result = engine.synchronize(task)
assert result.successful
assert result.skipped == 1
assert not result.errors
assert not (target / "ephemeral.txt").exists()
+1 -1
View File
@@ -1 +1 @@
1.0.11 1.0.12