diff --git a/src/boltclam/app.py b/src/boltclam/app.py index 046e42c..49a8610 100644 --- a/src/boltclam/app.py +++ b/src/boltclam/app.py @@ -5,7 +5,9 @@ from __future__ import annotations import logging import os import sys +import threading import time +import traceback from pathlib import Path from PyQt6.QtCore import QEventLoop, QLockFile, QRectF, Qt, QTimer @@ -18,7 +20,7 @@ from .auth import AuthService, CredentialStore, Session, SessionExpired from .clamav import ClamAVService from .config import AccountCache, SettingsStore from .context import AppContext -from .logging_setup import configure as configure_logging +from .logging_setup import configure as configure_logging, log_path from .quarantine import QuarantineService from .storage import Storage from .ui import brand, theme @@ -73,10 +75,17 @@ def _install_exception_hook() -> None: sys.__excepthook__(exc_type, exc_value, exc_traceback) return logger.critical("Unhandled exception", exc_info=(exc_type, exc_value, exc_traceback)) + # Qt widget calls are not safe from worker threads. Log there and + # leave the UI alone; the page's own status/toast reports the failure. + if threading.current_thread() is not threading.main_thread(): + return + detail = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)).strip() + if len(detail) > 3200: + detail = "…\n" + detail[-3200:] try: QMessageBox.critical( None, "BoltClam encountered an error", - f"{exc_type.__name__}: {exc_value}\n\nDetails were written to the BoltClam log.") + f"{detail}\n\nFull details: {log_path()}") except Exception: pass diff --git a/src/boltclam/realtime.py b/src/boltclam/realtime.py index 262e4c9..7f9bd5c 100644 --- a/src/boltclam/realtime.py +++ b/src/boltclam/realtime.py @@ -259,6 +259,16 @@ class DownloadWatcher(QThread): self._stop.set() def run(self) -> None: + try: + self._watch_loop() + except Exception: + logger.exception("Real-time watcher stopped after an error") + try: + self.watch_status.emit("Real-time watcher stopped") + except Exception: + pass + + def _watch_loop(self) -> None: import threading stop = threading.Event() diff --git a/tests/test_realtime.py b/tests/test_realtime.py index 5060ae3..4862497 100644 --- a/tests/test_realtime.py +++ b/tests/test_realtime.py @@ -58,6 +58,19 @@ def test_firefox_download_preference_is_watched(tmp_path, monkeypatch): assert download.resolve() in [path.resolve() for path in roots] +def test_watcher_stops_cleanly_instead_of_raising(monkeypatch, qapp): + messages: list[str] = [] + watcher = realtime.DownloadWatcher(interval=0.1) + watcher.watch_status.connect(messages.append) + monkeypatch.setattr(realtime, "browser_download_roots", lambda extra: (_ for _ in ()).throw( + RuntimeError("simulated watcher failure"))) + watcher.start() + assert watcher.wait(3000) + qapp.processEvents() + assert not watcher.isRunning() + assert "stopped" in " ".join(messages).lower() + + def test_copy_for_scan_preserves_bytes(tmp_path): source = tmp_path / "download.exe" source.write_bytes(b"sample bytes")