From 4518f4467840f35ae6d389825c8a891fecca1c2a Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 4 Oct 2026 17:17:29 -0300 Subject: [PATCH] fix: push log messages to the message bar from the main thread QTimer.singleShot(0, func) runs func on the calling thread, not the Qt main thread. A log message with push=True from a background task (for example the thickness calculator) changed the QGIS message bar from the worker thread, and QGIS stopped responding when the task ended. Send the message bar change to the main thread with a queued signal. --- loopstructural/toolbelt/log_handler.py | 68 ++++++++++++++++++-------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/loopstructural/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index 9a09043..e8645e6 100644 --- a/loopstructural/toolbelt/log_handler.py +++ b/loopstructural/toolbelt/log_handler.py @@ -8,12 +8,14 @@ # standard library import logging +import threading from functools import partial from typing import Callable # PyQGIS from qgis.core import QgsMessageLog, QgsMessageOutput from qgis.gui import QgsMessageBar +from qgis.PyQt.QtCore import QCoreApplication, QObject, Qt, QThread, pyqtSignal, pyqtSlot from qgis.PyQt.QtWidgets import QPushButton, QWidget from qgis.utils import iface @@ -27,6 +29,45 @@ # ################################## +class _MainThreadInvoker(QObject): + """Runs functions on the Qt main thread. + + A signal emitted from a worker thread is delivered to a slot of an object + that lives on the main thread through the main thread's event queue. + """ + + invoke = pyqtSignal(object) + + def __init__(self): + super().__init__() + self.invoke.connect(self._run, Qt.ConnectionType.QueuedConnection) + + @pyqtSlot(object) + def _run(self, func): + func() + + +_invoker = None +_invoker_lock = threading.Lock() + + +def _run_on_main_thread(func): + """Run `func` on the Qt main thread: now if this is the main thread, else queued.""" + global _invoker + app = QCoreApplication.instance() + if app is None or QThread.currentThread() == app.thread(): + func() + return + with _invoker_lock: + if _invoker is None: + invoker = _MainThreadInvoker() + # An object can only be moved from the thread it lives on, which + # is this thread, because it was made here. + invoker.moveToThread(app.thread()) + _invoker = invoker + _invoker.invoke.emit(func) + + class PlgLogger(logging.Handler): """Python logging handler supercharged with QGIS useful methods.""" @@ -146,30 +187,15 @@ def _do_push(): except Exception: logging.exception("Failed to push message to QGIS message bar") - # Try to schedule the UI interaction on the Qt main thread using QTimer + # The message bar is a widget, so only the Qt main thread can change it. + # QTimer.singleShot(0, func) does not do this: it runs func on the + # calling thread, so a call from a background task changed the message + # bar from the worker thread and locked the GUI. try: - try: - from qgis.PyQt.QtCore import QTimer as _QTimer - except Exception: - # fall back to qgis.PyQt/PySide2 if qgis.PyQt namespace isn't present - try: - from qgis.PyQt.QtCore import QTimer as _QTimer # type: ignore - except Exception: - try: - from PySide2.QtCore import QTimer as _QTimer # type: ignore - except Exception: - _QTimer = None - if _QTimer is not None: - _QTimer.singleShot(0, _do_push) - else: - # last resort: call directly (may block if called from background thread) - _do_push() + _run_on_main_thread(_do_push) except Exception: # ensure we do not raise from logging - try: - _do_push() - except Exception: - pass + pass class PlgLoggerHandler(logging.Handler):