diff --git a/electrum/base_crash_reporter.py b/electrum/base_crash_reporter.py index da22756c8..1720538c4 100644 --- a/electrum/base_crash_reporter.py +++ b/electrum/base_crash_reporter.py @@ -25,13 +25,15 @@ import locale import traceback import sys import queue -from typing import TYPE_CHECKING, NamedTuple, Optional +from typing import TYPE_CHECKING, NamedTuple, Optional, TypedDict +from types import TracebackType from .version import ELECTRUM_VERSION from . import constants from .i18n import _ from .util import make_aiohttp_session, error_text_str_to_safe_str from .logging import describe_os_version, Logger, get_git_version +from .crypto import sha256 if TYPE_CHECKING: from .network import ProxySettings @@ -68,9 +70,16 @@ class BaseCrashReporter(Logger): USER_COMMENT_PLACEHOLDER = _("Do not enter sensitive/private information here. " "The report will be visible on the public issue tracker.") - def __init__(self, exctype, value, tb): + exc_args: tuple[type[BaseException], BaseException, TracebackType | None] + + def __init__( + self, + exctype: type[BaseException], + excvalue: BaseException, + tb: TracebackType | None, + ): Logger.__init__(self) - self.exc_args = (exctype, value, tb) + self.exc_args = (exctype, excvalue, tb) def send_report(self, asyncio_loop, proxy: 'ProxySettings', *, timeout=None) -> CrashReportResponse: # FIXME the caller needs to catch generic "Exception", as this method does not have a well-defined API... @@ -82,7 +91,7 @@ class BaseCrashReporter(Logger): ] and ".electrum.org" in BaseCrashReporter.report_server): # Gah! Some kind of altcoin wants to send us crash reports. raise Exception(_("Missing report URL.")) - report = self.get_traceback_info() + report = self.get_traceback_info(*self.exc_args) report.update(self.get_additional_info()) report = json.dumps(report) coro = self.do_post(proxy, BaseCrashReporter.report_server + "/crash.json", data=report) @@ -111,21 +120,38 @@ class BaseCrashReporter(Logger): async with session.post(url, data=data, raise_for_status=True) as resp: return await resp.text() - def get_traceback_info(self): - exc_string = str(self.exc_args[1]) - stack = traceback.extract_tb(self.exc_args[2]) - readable_trace = self.__get_traceback_str_to_send() - id = { + @classmethod + def get_traceback_info( + cls, + exctype: type[BaseException], + excvalue: BaseException, + tb: TracebackType | None, + ) -> TypedDict('TBInfo', {'exc_string': str, 'stack': str, 'id': dict[str, str]}): + exc_string = str(excvalue) + stack = traceback.extract_tb(tb) + readable_trace = cls._get_traceback_str_to_send(exctype, excvalue, tb) + _id = { "file": stack[-1].filename if len(stack) else '', "name": stack[-1].name if len(stack) else '', - "type": self.exc_args[0].__name__ - } + "type": exctype.__name__ + } # note: this is the "id" the crash reporter server uses to group together reports. return { "exc_string": exc_string, "stack": readable_trace, - "id": id + "id": _id, } + @classmethod + def get_traceback_groupid_hash( + cls, + exctype: type[BaseException], + excvalue: BaseException, + tb: TracebackType | None, + ) -> bytes: + tb_info = cls.get_traceback_info(exctype, excvalue, tb) + _id = tb_info["id"] + return sha256(str(_id)) + def get_additional_info(self): args = { "app_version": get_git_version() or ELECTRUM_VERSION, @@ -142,15 +168,21 @@ class BaseCrashReporter(Logger): pass return args - def __get_traceback_str_to_send(self) -> str: + @classmethod + def _get_traceback_str_to_send( + cls, + exctype: type[BaseException], + excvalue: BaseException, + tb: TracebackType | None, + ) -> str: # make sure that traceback sent to crash reporter contains # e.__context__ and e.__cause__, i.e. if there was a chain of # exceptions, we want the full traceback for the whole chain. - return "".join(traceback.format_exception(*self.exc_args)) + return "".join(traceback.format_exception(exctype, excvalue, tb)) def _get_traceback_str_to_display(self) -> str: # overridden in Qt subclass - return self.__get_traceback_str_to_send() + return self._get_traceback_str_to_send(*self.exc_args) def get_report_string(self): info = self.get_additional_info() diff --git a/electrum/gui/qml/components/ExceptionDialog.qml b/electrum/gui/qml/components/ExceptionDialog.qml index 3a60ae22a..e4159232a 100644 --- a/electrum/gui/qml/components/ExceptionDialog.qml +++ b/electrum/gui/qml/components/ExceptionDialog.qml @@ -77,15 +77,16 @@ ElDialog Layout.fillWidth: true Layout.preferredWidth: 3 text: qsTr('Send Bug Report') - onClicked: AppController.sendReport(user_text.text) - } - Button { - Layout.fillWidth: true - Layout.preferredWidth: 2 - text: qsTr('Never') onClicked: { - AppController.showNever() - close() + var dialog = app.messageDialog.createObject(app, { + text: qsTr('Confirm to send bugreport?'), + yesno: true, + z: 1001 // assure topmost of all other dialogs + }) + dialog.accepted.connect(function() { + AppController.sendReport(user_text.text) + }) + dialog.open() } } Button { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 38de0c407..349f89e5c 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -344,7 +344,7 @@ class QEAppController(BaseCrashReporter, QObject): @pyqtSlot(result='QVariantMap') def crashData(self): return { - 'traceback': self.get_traceback_info(), + 'traceback': self.get_traceback_info(*self.exc_args), 'extra': self.get_additional_info(), 'reportstring': self.get_report_string() } @@ -379,10 +379,6 @@ class QEAppController(BaseCrashReporter, QObject): self.sendingBugreport.emit() threading.Thread(target=report_task, daemon=True).start() - @pyqtSlot() - def showNever(self): - self.config.SHOW_CRASH_REPORTER = False - def _get_traceback_str_to_display(self) -> str: # The msg_box that shows the report uses rich_text=True, so # if traceback contains special HTML characters, e.g. '<', @@ -541,6 +537,7 @@ class Exception_Hook(QObject, Logger): Logger.__init__(self) assert self._INSTANCE is None, "Exception_Hook is supposed to be a singleton" self.wallet_types_seen = set() # type: Set[str] + self.exception_ids_seen = set() # type: Set[bytes] sys.excepthook = self.handler threading.excepthook = self.handler @@ -551,9 +548,6 @@ class Exception_Hook(QObject, Logger): @classmethod def maybe_setup(cls, *, wallet: 'Abstract_Wallet' = None, slot=None) -> None: - if not QEConfig.instance.config.SHOW_CRASH_REPORTER: - EarlyExceptionsQueue.set_hook_as_ready() # flush already queued exceptions - return if not cls._INSTANCE: cls._INSTANCE = Exception_Hook(slot=slot) if wallet: @@ -561,4 +555,8 @@ class Exception_Hook(QObject, Logger): def handler(self, *exc_info): self.logger.error('exception caught by crash reporter', exc_info=exc_info) + groupid_hash = BaseCrashReporter.get_traceback_groupid_hash(*exc_info) + if groupid_hash in self.exception_ids_seen: + return # to avoid annoying the user, only show crash reporter once per exception groupid + self.exception_ids_seen.add(groupid_hash) self._report_exception.emit(*exc_info) diff --git a/electrum/gui/qt/exception_window.py b/electrum/gui/qt/exception_window.py index f166d8078..848b97cc0 100644 --- a/electrum/gui/qt/exception_window.py +++ b/electrum/gui/qt/exception_window.py @@ -83,14 +83,10 @@ class Exception_Window(BaseCrashReporter, QWidget, MessageBoxMixin, Logger): buttons = QHBoxLayout() report_button = QPushButton(_('Send Bug Report')) - report_button.clicked.connect(lambda _checked: self.send_report()) + report_button.clicked.connect(lambda _checked: self._ask_for_confirm_to_send_report()) report_button.setIcon(read_QIcon("tab_send.png")) buttons.addWidget(report_button) - never_button = QPushButton(_('Never')) - never_button.clicked.connect(lambda _checked: self.show_never()) - buttons.addWidget(never_button) - close_button = QPushButton(_('Not Now')) close_button.clicked.connect(lambda _checked: self.close()) buttons.addWidget(close_button) @@ -103,6 +99,10 @@ class Exception_Window(BaseCrashReporter, QWidget, MessageBoxMixin, Logger): self.setLayout(main_box) self.show() + def _ask_for_confirm_to_send_report(self): + if self.question("Confirm to send bugreport?"): + self.send_report() + def send_report(self): def on_success(response: CrashReportResponse): text = response.text @@ -133,10 +133,6 @@ class Exception_Window(BaseCrashReporter, QWidget, MessageBoxMixin, Logger): Exception_Window._active_window = None self.close() - def show_never(self): - self.config.SHOW_CRASH_REPORTER = False - self.close() - def closeEvent(self, event): self.on_close() event.accept() @@ -181,6 +177,7 @@ class Exception_Hook(QObject, Logger): assert self._INSTANCE is None, "Exception_Hook is supposed to be a singleton" self.config = config self.wallet_types_seen = set() # type: Set[str] + self.exception_ids_seen = set() # type: Set[bytes] sys.excepthook = self.handler self._report_exception.connect(_show_window) @@ -188,9 +185,6 @@ class Exception_Hook(QObject, Logger): @classmethod def maybe_setup(cls, *, config: 'SimpleConfig', wallet: 'Abstract_Wallet' = None) -> None: - if not config.SHOW_CRASH_REPORTER: - EarlyExceptionsQueue.set_hook_as_ready() # flush already queued exceptions - return if not cls._INSTANCE: cls._INSTANCE = Exception_Hook(config=config) if wallet: @@ -198,6 +192,10 @@ class Exception_Hook(QObject, Logger): def handler(self, *exc_info): self.logger.error('exception caught by crash reporter', exc_info=exc_info) + groupid_hash = BaseCrashReporter.get_traceback_groupid_hash(*exc_info) + if groupid_hash in self.exception_ids_seen: + return # to avoid annoying the user, only show crash reporter once per exception groupid + self.exception_ids_seen.add(groupid_hash) self._report_exception.emit(self.config, *exc_info) diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 913055a17..e5de7b67c 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -892,7 +892,6 @@ Warning: setting this to too low will result in lots of payment failures."""), long_desc=lambda: _("Select which language is used in the GUI (after restart)."), ) BLOCKCHAIN_PREFERRED_BLOCK = ConfigVar('blockchain_preferred_block', default=None) - SHOW_CRASH_REPORTER = ConfigVar('show_crash_reporter', default=True, type_=bool) DONT_SHOW_TESTNET_WARNING = ConfigVar('dont_show_testnet_warning', default=False, type_=bool) RECENTLY_OPEN_WALLET_FILES = ConfigVar('recently_open', default=None) IO_DIRECTORY = ConfigVar('io_dir', default=os.path.expanduser('~'), type_=str)