Merge pull request #10894 from f321x/fix_qml_use_after_free
What changed, and why it matters
This commit fixes a class of crash bugs in Electrum's Qt/QML graphical user interface. When a user closes a dialog or window while a background task is still running, the background task can try to talk to a Qt object that has already been destroyed, causing a RuntimeError and possibly crashing the app. The patch adds a helper that safely ignores these 'already destroyed' errors instead of letting them propagate. It does not appear to be a security vulnerability that an attacker can exploit to steal funds or run malicious code; it is a stability/robustness fix.
Treat as a normal stability fix. No urgent security response is indicated. Reviewers may want to confirm that ignore_if_destroyed does not mask genuine logic errors by verifying that sip.isdeleted() is checked before swallowing RuntimeError, which the diff shows it does. Continue monitoring for any related crash reports.
Security signals we found
Use-after-free mitigation in Qt/QML Python wrappers
RuntimeError suppression limited to sip.isdeleted() objects
Async callback safety in QML dialogs
Hardware wallet dialog lifecycle hardening
Evidence from the diff
The change introduces ignore_if_destroyed(), a context manager/decorator in electrum/gui/common_qt/util.py that catches RuntimeError only when sip.isdeleted(qobj) is true, and re-raises any other RuntimeError. It is applied to callbacks and worker threads in QML channel details/opener, invoice handling, transaction finalization, the wallet wizard hardware-device scan, and hardware-wallet dialog reuse. The goal is to prevent use-after-free style access to deleted QObjects when asynchronous Python code outlives the QML/Qt dialog that spawned it. The patch is defensive and narrows the swallowed exception to cases where the object is actually deleted.
Changed components
electrum/gui/common_qt/util.pyelectrum/gui/qml/qechanneldetails.pyelectrum/gui/qml/qechannelopener.pyelectrum/gui/qml/qeinvoice.pyelectrum/gui/qml/qetxfinalizer.pyelectrum/gui/qt/wizard/wallet.pyelectrum/hw_wallet/qt.pyInspect captured patch +52 / −23
### electrum/gui/common_qt/util.py
@@ -1,21 +1,40 @@
import queue
import sys
+from contextlib import contextmanager
from functools import wraps
-from typing import Optional, NamedTuple, Callable
+from typing import Optional, NamedTuple, Callable, Iterator
import os.path
-from PyQt6 import QtGui
-from PyQt6.QtCore import Qt, QThread, pyqtSignal
+from PyQt6 import QtGui, sip
+from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal
from PyQt6.QtGui import QColor, QPen, QPaintDevice, QFontDatabase, QImage
import qrcode
from electrum.i18n import _
-from electrum.logging import Logger
+from electrum.logging import Logger, get_logger
from electrum.util import EventListener, event_listener
+_logger = get_logger(__name__)
+
_cached_font_ids: dict[str, int] = {}
+@contextmanager
+def ignore_if_destroyed(qobj: QObject) -> Iterator[None]:
+ """
+ Objects owned by qt (e.g. a child of a dialog) or by QML are destroyed as soon as the user
+ closes the dialog, while threads and tasks may still hold a reference to the python wrapper,
+ and writing a property or emitting a signal on it then raises RuntimeError.
+ Any other RuntimeError is re-raised.
+ """
+ try:
+ yield
+ except RuntimeError:
+ if not sip.isdeleted(qobj):
+ raise
+ _logger.debug(f'{type(qobj).__name__} has been destroyed, ignoring')
+
+
def get_font_id(filename: str) -> int:
font_id = _cached_font_ids.get(filename)
if font_id is not None:
### electrum/gui/qml/qechanneldetails.py
@@ -11,7 +11,7 @@
from electrum.lnchannel import ChanCloseOption, ChannelState, AbstractChannel, Channel, ChannelBackup
from electrum.util import format_short_id, event_listener
-from electrum.gui.common_qt.util import QtEventListener
+from electrum.gui.common_qt.util import QtEventListener, ignore_if_destroyed
from .auth import AuthMixin, auth_protect
from .qewallet import QEWallet
@@ -285,17 +285,15 @@ def closeChannel(self, closetype):
def do_close_channel(self, closetype: str):
channel_id = self._channel.channel_id
+ @ignore_if_destroyed(self)
def handle_result(success: bool, msg: str = ''):
- try:
- if success:
- self.channelCloseSuccess.emit()
- else:
- self.channelCloseFailed.emit(msg)
+ if success:
+ self.channelCloseSuccess.emit()
+ else:
+ self.channelCloseFailed.emit(msg)
- self._is_closing = False
- self.isClosingChanged.emit()
- except RuntimeError: # QEChannelDetails might be deleted at this point if the user closed the dialog.
- pass
+ self._is_closing = False
+ self.isClosingChanged.emit()
def do_close():
try:
### electrum/gui/qml/qechannelopener.py
@@ -4,10 +4,12 @@
from typing import Optional
import electrum_ecc as ecc
+from PyQt6 import sip
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QVariant
from electrum.i18n import _
from electrum.gui import messages
+from electrum.gui.common_qt.util import ignore_if_destroyed
from electrum.util import bfh
from electrum.lnutil import MIN_FUNDING_SAT
from electrum.lntransport import extract_nodeid, ConnStringFormatError
@@ -230,6 +232,7 @@ def do_open_channel(self, funding_tx: PartialTransaction, conn_str, password):
funding_sat = funding_tx.output_value_for_address(DummyAddress.CHANNEL)
lnworker = self._wallet.wallet.lnworker
+ @ignore_if_destroyed(self)
def open_thread():
error = None
try:
@@ -253,6 +256,8 @@ def open_thread():
except (CancelledError, TimeoutError):
error = _('Could not connect to channel peer')
except Exception as e:
+ if isinstance(e, RuntimeError) and sip.isdeleted(self):
+ return # qt object already deleted
error = str(e)
if not error:
error = repr(e)
@@ -290,6 +295,7 @@ def calc_max():
self._amount.satsInt = amount if amount else 0
finally:
self._updating_max = False
- self.validate()
+ with ignore_if_destroyed(self):
+ self.validate()
threading.Thread(target=calc_max, daemon=True).start()
### electrum/gui/qml/qeinvoice.py
@@ -20,7 +20,7 @@
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
from electrum.util import event_listener, now, InvoiceError
-from electrum.gui.common_qt.util import QtEventListener
+from electrum.gui.common_qt.util import QtEventListener, ignore_if_destroyed
from .qetypes import QEAmount
from .qewallet import QEWallet
@@ -442,6 +442,7 @@ def updateMaxAmount(self):
self._updating_max = True
+ @ignore_if_destroyed(self)
def calc_max(address):
try:
outputs = [PartialTxOutput(scriptpubkey=address_to_script(address), value='!')]
### electrum/gui/qml/qetxfinalizer.py
@@ -21,7 +21,7 @@
from electrum.network import NetworkException
from electrum.gui import messages
-from electrum.gui.common_qt.util import QtEventListener
+from electrum.gui.common_qt.util import QtEventListener, ignore_if_destroyed
from .qewallet import QEWallet
from .qetypes import QEAmount
@@ -1161,6 +1161,7 @@ def make_sweep_tx(self):
def update_privkeys(self):
privkeys = keystore.get_private_keys(self._private_keys)
+ @ignore_if_destroyed(self)
def fetch_privkeys_info():
try:
self._txins = self._wallet.wallet.network.run_from_another_thread(sweep_preparations(privkeys, self._wallet.wallet.network))
### electrum/gui/qt/wizard/wallet.py
@@ -25,6 +25,7 @@
from electrum.wallet_db import WalletDB
from electrum.wizard import NewWalletWizard, KeystoreWizard, WizardViewState
+from electrum.gui.common_qt.util import ignore_if_destroyed
from electrum.gui.qt.bip39_recovery_dialog import Bip39RecoveryDialog
from electrum.gui.qt.password_dialog import PasswordLayout, PW_NEW, MSG_ENTER_PASSWORD, PasswordLayoutForHW
from electrum.gui.qt.seed_dialog import SeedWidget, MSG_PASSPHRASE_WARN_ISSUE4566, KeysWidget
@@ -1175,6 +1176,7 @@ def scan_devices(self):
self.busy_msg = _('Scanning devices...')
self.busy = True
+ @ignore_if_destroyed(self)
def scan_task():
# check available plugins
supported_plugins = self.plugins.get_hardware_support()
### electrum/hw_wallet/qt.py
@@ -36,7 +36,7 @@
from electrum.util import UserCancelled, UserFacingException, ChoiceItem
from electrum.plugin import hook
-from electrum.gui.common_qt.util import TaskThread
+from electrum.gui.common_qt.util import TaskThread, ignore_if_destroyed
from electrum.gui.qt.password_dialog import PasswordLayout, PW_PASSPHRASE
from electrum.gui.qt.util import (
read_QIcon, WWLabel, OkButton, WindowModalDialog, Buttons, CancelButton, char_width_in_lineedit, PasswordLineEdit,
@@ -181,10 +181,11 @@ def message_dialog(self, msg, on_cancel=None):
# window-modal dialog each time is slow and visibly janky on macOS
# (the modal "sheet" animates closed/open between outputs). See #10718.
if self.dialog is not None and self._dialog_on_cancel == on_cancel:
- self._dialog_label.setText(msg)
- if not self.dialog.isVisible(): # e.g. was hidden by a user "cancel"
- self.dialog.show()
- return
+ with ignore_if_destroyed(self.dialog):
+ self._dialog_label.setText(msg)
+ if not self.dialog.isVisible(): # e.g. was hidden by a user "cancel"
+ self.dialog.show()
+ return # dialog gets rebuild if a RuntimeError was raised and the return is skipped
self.clear_dialog()
title = self.MESSAGE_DIALOG_TITLE
if title is None:
@@ -207,7 +208,8 @@ def error_dialog(self, msg, blocking):
def clear_dialog(self):
if self.dialog:
- self.dialog.accept()
+ with ignore_if_destroyed(self.dialog):
+ self.dialog.accept()
self.dialog = None
self._dialog_label = None
self._dialog_on_cancel = NoneWhy this scored 33/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.