diff --git a/CHANGELOG.md b/CHANGELOG.md index bf757e8..97c4c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Ä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 ### Behoben diff --git a/pyproject.toml b/pyproject.toml index 6d7359f..dad07a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "syncvito" -version = "1.0.11" +version = "1.0.12" 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 cda8176..27fc9a9 100644 --- a/src/syncvito/coordinator.py +++ b/src/syncvito/coordinator.py @@ -88,6 +88,8 @@ class SyncCoordinator(QObject): self._queue: deque[str] = deque() self._running_task_id: str | None = None 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._task_states: dict[str, TaskRuntimeState] = { 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} 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_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 = { 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) if not task.enabled: 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) 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) @@ -179,6 +185,8 @@ class SyncCoordinator(QObject): task = self._task(task_id) if task is None or not task.enabled: return + self._retry_attempts.pop(task_id, None) + self._retry_cooldowns.pop(task_id, None) if self.config.settings.paused: if task_id != self._running_task_id and task_id not in self._queue: self._queue.append(task_id) @@ -229,7 +237,9 @@ class SyncCoordinator(QObject): """Aktualisiert alle Dateisystemüberwachungen.""" errors = self.monitor.rebuild(self.config.tasks) 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.log_created.emit("Echtzeit", "Fehler", message) self._emit_overall_status() @@ -315,12 +325,17 @@ class SyncCoordinator(QObject): self._emit_lock_summary() if result.errors: # 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: 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) + 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" self.log_created.emit(task.name, "Erfolg", summary) 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: task = self._task(task_id) 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._emit_lock_summary() self._set_task_state(task_id, TaskRuntimeState.ERROR) @@ -372,9 +389,36 @@ class SyncCoordinator(QObject): def _check_retries(self) -> None: if self.config.settings.paused or self.is_running: return + now = datetime.now() + ready_tasks: list[str] = [] 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 + 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( "Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft." ) diff --git a/src/syncvito/main_window.py b/src/syncvito/main_window.py index 0d0ad90..74dabaf 100644 --- a/src/syncvito/main_window.py +++ b/src/syncvito/main_window.py @@ -803,6 +803,15 @@ class MainWindow(QMainWindow): self.log_table.setItem(row, column, item) if self.log_table.rowCount() > 1000: 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() def _set_global_status(self, status: str, text: str) -> None: diff --git a/src/syncvito/sync_engine.py b/src/syncvito/sync_engine.py index a3678d3..78f7fee 100644 --- a/src/syncvito/sync_engine.py +++ b/src/syncvito/sync_engine.py @@ -141,6 +141,11 @@ class SyncEngine: if stage_name not in stages: stages.append(stage_name) + known_locking_processes: dict[Path, tuple[LockingProcess, ...]] = {} + rm_query_count = 0 + last_progress_time = 0.0 + current_stage = "" + if progress: progress(0, "Änderungen werden vorbereitet …") for index, operation in enumerate(operations, start=1): @@ -154,6 +159,9 @@ class SyncEngine: try: self._execute(operation, result) except (OSError, shutil.Error) as exc: + if isinstance(exc, FileNotFoundError) and operation.source and not operation.source.exists(): + result.skipped += 1 + continue failed_path = ( Path(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) 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)) elif isinstance(exc, PermissionError): result.errors.append(f"Zugriff verweigert: {failed_path} ({exc})") else: 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: stage_idx = stages.index(stage) + 1 detail = f"[{stage_idx}/{len(stages)} {stage}] {operation.kind}: {operation.relative}" @@ -344,6 +367,9 @@ class SyncEngine: return if operation.source is None: return + if not operation.source.exists(): + result.skipped += 1 + return operation.target.parent.mkdir(parents=True, exist_ok=True) if operation.conflict_copy and operation.target.exists(): conflict_path = _conflict_path(operation.target) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index bb809aa..9a3e62a 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -73,6 +73,30 @@ def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch: monkeypatch.setattr(coordinator, "enqueue", queued.append) coordinator._check_retries() 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( diff --git a/tests/test_sync_engine.py b/tests/test_sync_engine.py index f18d9a3..0b55222 100644 --- a/tests/test_sync_engine.py +++ b/tests/test_sync_engine.py @@ -269,3 +269,27 @@ def test_pause_and_resume_continues_where_interrupted(tmp_path: Path) -> None: assert (target / "a.txt").exists() assert (target / "b.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() diff --git a/version.txt b/version.txt index 59e9e60..bb83058 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.11 +1.0.12