From 121b7b767eb05a2eaa68b61d2efbdfb622adf31a Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 16 Jul 2025 09:47:32 +0200 Subject: [PATCH 1/2] fix: qt: handle main_window.gui_object.timer being None When closing Electrum with open `ConfirmTxDialog` the following exception is raised: ``` 1319.20 | E | gui.qt.exception_window.Exception_Hook | exception caught by crash reporter Traceback (most recent call last): File "/home/user/code/electrum-fork/electrum/gui/qt/send_tab.py", line 575, in do_pay_or_get_invoice self.do_pay_invoice(self.pending_invoice) ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^ File "/home/user/code/electrum-fork/electrum/gui/qt/send_tab.py", line 602, in do_pay_invoice self.pay_onchain_dialog(invoice.outputs, invoice=invoice) ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/user/code/electrum-fork/electrum/gui/qt/send_tab.py", line 328, in pay_onchain_dialog tx, is_preview = self.window.confirm_tx_dialog(make_tx, output_value, batching_candidates=candidates) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/user/code/electrum-fork/electrum/gui/qt/main_window.py", line 1502, in confirm_tx_dialog return d.run(), d.is_preview ~~~~~^^ File "/home/user/code/electrum-fork/electrum/gui/qt/confirm_tx_dialog.py", line 477, in run self.stop_editor_updates() ~~~~~~~~~~~~~~~~~~~~~~~~^^ File "/home/user/code/electrum-fork/electrum/gui/qt/confirm_tx_dialog.py", line 133, in stop_editor_updates self.main_window.gui_object.timer.timeout.disconnect(self.timer_actions) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AttributeError: 'NoneType' object has no attribute 'timeout' ``` This can be prevented by checking if `main_window.gui_object.timer` is None before trying to disconnect it. --- electrum/gui/qt/confirm_tx_dialog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index e6c80f203..e06898115 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -130,7 +130,8 @@ class TxEditor(WindowModalDialog): self._update_widgets() def stop_editor_updates(self): - self.main_window.gui_object.timer.timeout.disconnect(self.timer_actions) + if self.main_window.gui_object.timer is not None: + self.main_window.gui_object.timer.timeout.disconnect(self.timer_actions) def update_tx(self, *, fallback_to_zero_fee: bool = False): # expected to set self.tx, self.message and self.error From 0e055f812790e0697477b14b7947248ba1e1f0b0 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 16 Jul 2025 10:20:23 +0200 Subject: [PATCH 2/2] qt: don't share `ElectrumGui.QTimer`, use self-contained `QTimer` so its lifecycle is synced in `ElectrumWindow`, `TxEditor`, `SwapDialog` Also use `QTimer` classmethod for `.singleShot()` occurrences. --- electrum/gui/qt/__init__.py | 11 +---------- electrum/gui/qt/confirm_tx_dialog.py | 11 +++++++---- electrum/gui/qt/main_window.py | 14 ++++++++++---- electrum/gui/qt/swap_dialog.py | 10 ++++++++-- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 48a8c0ec9..de41bcafd 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -168,11 +168,6 @@ class ElectrumGui(BaseElectrumGui, Logger): self.translator = ElectrumTranslator() self.app.installTranslator(self.translator) self._cleaned_up = False - # timer - self.timer = QTimer(self.app) - self.timer.setSingleShot(False) - self.timer.setInterval(500) # msec - self.network_dialog = None self.lightning_dialog = None self._num_wizards_in_progress = 0 @@ -287,9 +282,6 @@ class ElectrumGui(BaseElectrumGui, Logger): if self.lightning_dialog: self.lightning_dialog.close() self.lightning_dialog = None - # Shut down the timer cleanly - self.timer.stop() - self.timer = None # clipboard persistence. see http://www.mail-archive.com/pyqt@riverbankcomputing.com/msg17328.html event = QtCore.QEvent(QtCore.QEvent.Type.Clipboard) self.app.sendEvent(self.app.clipboard(), event) @@ -589,7 +581,6 @@ class ElectrumGui(BaseElectrumGui, Logger): self.logger.exception('') return # start wizard to select/create wallet - self.timer.start() path = self.config.get_wallet_path() try: if not self.start_new_window(path, self.config.get('url'), app_is_starting=True): @@ -622,7 +613,7 @@ class ElectrumGui(BaseElectrumGui, Logger): self.app.clipboard().setText(text) message = _("Text copied to Clipboard") if title is None else _("{} copied to Clipboard").format(title) # tooltip cannot be displayed immediately when called from a menu; wait 200ms - self.timer.singleShot(200, lambda: QToolTip.showText(QCursor.pos(), message, None)) + QTimer.singleShot(200, lambda: QToolTip.showText(QCursor.pos(), message, None)) def standalone_exception_dialog(exception: Union[str, BaseException]) -> None: diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index e06898115..9ec7ab7c0 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -27,7 +27,7 @@ from decimal import Decimal from functools import partial from typing import TYPE_CHECKING, Optional, Union, Callable -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton, QMenu, QComboBox @@ -114,7 +114,11 @@ class TxEditor(WindowModalDialog): self.update_fee_target() self.resize(self.layout().sizeHint()) - self.main_window.gui_object.timer.timeout.connect(self.timer_actions) + self.timer = QTimer(self) + self.timer.setInterval(500) + self.timer.setSingleShot(False) + self.timer.timeout.connect(self.timer_actions) + self.timer.start() def is_batching(self) -> bool: return self._base_tx is not None @@ -130,8 +134,7 @@ class TxEditor(WindowModalDialog): self._update_widgets() def stop_editor_updates(self): - if self.main_window.gui_object.timer is not None: - self.main_window.gui_object.timer.timeout.disconnect(self.timer_actions) + self.timer.stop() def update_tx(self, *, fallback_to_zero_fee: bool = False): # expected to set self.tx, self.message and self.error diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 064774e03..4d6d827c7 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -38,7 +38,7 @@ from typing import Optional, TYPE_CHECKING, Sequence, Union, Dict, Mapping, Call import concurrent.futures from PyQt6.QtGui import QPixmap, QKeySequence, QIcon, QCursor, QFont, QFontMetrics, QAction, QShortcut -from PyQt6.QtCore import Qt, QRect, QStringListModel, QSize, pyqtSignal +from PyQt6.QtCore import Qt, QRect, QStringListModel, QSize, pyqtSignal, QTimer from PyQt6.QtWidgets import (QMessageBox, QTabWidget, QMenuBar, QFileDialog, QCheckBox, QLabel, QVBoxLayout, QGridLayout, QLineEdit, QHBoxLayout, QPushButton, QScrollArea, QTextEdit, QMainWindow, QInputDialog, QWidget, QSizePolicy, QStatusBar, QToolTip, @@ -286,7 +286,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): # update fee slider in case we missed the callback #self.fee_slider.update() self.load_wallet(wallet) - gui_object.timer.timeout.connect(self.timer_actions) + + self.timer = QTimer(self) + self.timer.setInterval(500) + self.timer.setSingleShot(False) + self.timer.timeout.connect(self.timer_actions) + self.timer.start() + self.contacts.fetch_openalias(self.config) # If the option hasn't been set yet @@ -1213,7 +1219,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): def show_tooltip_after_delay(self, message): # tooltip cannot be displayed immediately when called from a menu; wait 200ms - self.gui_object.timer.singleShot(200, lambda: QToolTip.showText(QCursor.pos(), message, self)) + QTimer.singleShot(200, lambda: QToolTip.showText(QCursor.pos(), message, self)) def toggle_qr_window(self): from . import qrwindow @@ -2809,7 +2815,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self._update_check_thread.stop() if self.tray: self.tray = None - self.gui_object.timer.timeout.disconnect(self.timer_actions) + self.timer.stop() self.gui_object.close_window(self) def cpfp_dialog(self, parent_tx: Transaction) -> None: diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py index 8c5447b27..fbf1c8e2c 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -1,7 +1,7 @@ import enum from typing import TYPE_CHECKING, Optional, Union, Tuple, Sequence -from PyQt6.QtCore import pyqtSignal, Qt +from PyQt6.QtCore import pyqtSignal, Qt, QTimer from PyQt6.QtGui import QIcon, QPixmap, QColor from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton from PyQt6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView @@ -126,7 +126,13 @@ class SwapDialog(WindowModalDialog, QtEventListener): self.init_recv_amount(recv_amount_sat) self.update() self.needs_tx_update = True - self.window.gui_object.timer.timeout.connect(self.timer_actions) + + self.timer = QTimer(self) + self.timer.setInterval(500) + self.timer.setSingleShot(False) + self.timer.timeout.connect(self.timer_actions) + self.timer.start() + self.fee_slider.update() self.register_callbacks()