Improve crash diagnostics and isolate watcher-thread failures

Main-thread unhandled exceptions now show the traceback and log path; worker-thread exceptions log without touching Qt widgets. The download watcher catches its own loop failures and reports that it stopped instead of surfacing a blank error dialog. Adds a regression test for the watcher failure path.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
TheMajesticMagician
2026-10-25 17:22:20 -06:00
co-authored by Claude Code
parent ffe95020bc
commit 47f520fb8f
3 changed files with 34 additions and 2 deletions
+11 -2
View File
@@ -5,7 +5,9 @@ from __future__ import annotations
import logging import logging
import os import os
import sys import sys
import threading
import time import time
import traceback
from pathlib import Path from pathlib import Path
from PyQt6.QtCore import QEventLoop, QLockFile, QRectF, Qt, QTimer 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 .clamav import ClamAVService
from .config import AccountCache, SettingsStore from .config import AccountCache, SettingsStore
from .context import AppContext 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 .quarantine import QuarantineService
from .storage import Storage from .storage import Storage
from .ui import brand, theme from .ui import brand, theme
@@ -73,10 +75,17 @@ def _install_exception_hook() -> None:
sys.__excepthook__(exc_type, exc_value, exc_traceback) sys.__excepthook__(exc_type, exc_value, exc_traceback)
return return
logger.critical("Unhandled exception", exc_info=(exc_type, exc_value, exc_traceback)) 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: try:
QMessageBox.critical( QMessageBox.critical(
None, "BoltClam encountered an error", 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: except Exception:
pass pass
+10
View File
@@ -259,6 +259,16 @@ class DownloadWatcher(QThread):
self._stop.set() self._stop.set()
def run(self) -> None: 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 import threading
stop = threading.Event() stop = threading.Event()
+13
View File
@@ -58,6 +58,19 @@ def test_firefox_download_preference_is_watched(tmp_path, monkeypatch):
assert download.resolve() in [path.resolve() for path in roots] 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): def test_copy_for_scan_preserves_bytes(tmp_path):
source = tmp_path / "download.exe" source = tmp_path / "download.exe"
source.write_bytes(b"sample bytes") source.write_bytes(b"sample bytes")