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:
co-authored by
Claude Code
parent
ffe95020bc
commit
47f520fb8f
+11
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user