Skip to content

Commit cf2ebb9

Browse files
authored
fix: push log messages to the message bar from the main thread (#127)
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.
1 parent 59315b0 commit cf2ebb9

1 file changed

Lines changed: 47 additions & 21 deletions

File tree

‎loopstructural/toolbelt/log_handler.py‎

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88

99
# standard library
1010
import logging
11+
import threading
1112
from functools import partial
1213
from typing import Callable
1314

1415
# PyQGIS
1516
from qgis.core import QgsMessageLog, QgsMessageOutput
1617
from qgis.gui import QgsMessageBar
18+
from qgis.PyQt.QtCore import QCoreApplication, QObject, Qt, QThread, pyqtSignal, pyqtSlot
1719
from qgis.PyQt.QtWidgets import QPushButton, QWidget
1820
from qgis.utils import iface
1921

@@ -27,6 +29,45 @@
2729
# ##################################
2830

2931

32+
class _MainThreadInvoker(QObject):
33+
"""Runs functions on the Qt main thread.
34+
35+
A signal emitted from a worker thread is delivered to a slot of an object
36+
that lives on the main thread through the main thread's event queue.
37+
"""
38+
39+
invoke = pyqtSignal(object)
40+
41+
def __init__(self):
42+
super().__init__()
43+
self.invoke.connect(self._run, Qt.ConnectionType.QueuedConnection)
44+
45+
@pyqtSlot(object)
46+
def _run(self, func):
47+
func()
48+
49+
50+
_invoker = None
51+
_invoker_lock = threading.Lock()
52+
53+
54+
def _run_on_main_thread(func):
55+
"""Run `func` on the Qt main thread: now if this is the main thread, else queued."""
56+
global _invoker
57+
app = QCoreApplication.instance()
58+
if app is None or QThread.currentThread() == app.thread():
59+
func()
60+
return
61+
with _invoker_lock:
62+
if _invoker is None:
63+
invoker = _MainThreadInvoker()
64+
# An object can only be moved from the thread it lives on, which
65+
# is this thread, because it was made here.
66+
invoker.moveToThread(app.thread())
67+
_invoker = invoker
68+
_invoker.invoke.emit(func)
69+
70+
3071
class PlgLogger(logging.Handler):
3172
"""Python logging handler supercharged with QGIS useful methods."""
3273

@@ -146,30 +187,15 @@ def _do_push():
146187
except Exception:
147188
logging.exception("Failed to push message to QGIS message bar")
148189

149-
# Try to schedule the UI interaction on the Qt main thread using QTimer
190+
# The message bar is a widget, so only the Qt main thread can change it.
191+
# QTimer.singleShot(0, func) does not do this: it runs func on the
192+
# calling thread, so a call from a background task changed the message
193+
# bar from the worker thread and locked the GUI.
150194
try:
151-
try:
152-
from qgis.PyQt.QtCore import QTimer as _QTimer
153-
except Exception:
154-
# fall back to qgis.PyQt/PySide2 if qgis.PyQt namespace isn't present
155-
try:
156-
from qgis.PyQt.QtCore import QTimer as _QTimer # type: ignore
157-
except Exception:
158-
try:
159-
from PySide2.QtCore import QTimer as _QTimer # type: ignore
160-
except Exception:
161-
_QTimer = None
162-
if _QTimer is not None:
163-
_QTimer.singleShot(0, _do_push)
164-
else:
165-
# last resort: call directly (may block if called from background thread)
166-
_do_push()
195+
_run_on_main_thread(_do_push)
167196
except Exception:
168197
# ensure we do not raise from logging
169-
try:
170-
_do_push()
171-
except Exception:
172-
pass
198+
pass
173199

174200

175201
class PlgLoggerHandler(logging.Handler):

0 commit comments

Comments
 (0)