From adb292565feb98873590c2c4447addc2e3aabb23 Mon Sep 17 00:00:00 2001
From: Kien Le <kien.le@earthscope.org>
Date: Thu, 12 Dec 2024 11:24:50 -0700
Subject: [PATCH] Fix PySide6.8 signal-slot threading problem

---
 sohstationviewer/controller/util.py           | 29 ++++++++++++++-
 sohstationviewer/model/data_loader.py         | 11 ++++--
 .../multi_threaded_plotting_widget.py         |  4 +++
 tests/model/mseed_data/test_mseed.py          | 35 +++++++++++--------
 4 files changed, 61 insertions(+), 18 deletions(-)

diff --git a/sohstationviewer/controller/util.py b/sohstationviewer/controller/util.py
index d42f6f323..b68469620 100644
--- a/sohstationviewer/controller/util.py
+++ b/sohstationviewer/controller/util.py
@@ -10,7 +10,8 @@ from pathlib import Path
 from typing import Tuple, List, Union, Dict
 
 from PySide6 import QtCore
-from PySide6.QtWidgets import QTextBrowser
+from PySide6.QtCore import QObject, Slot
+from PySide6.QtWidgets import QTextBrowser, QApplication
 
 from obspy import UTCDateTime
 
@@ -18,6 +19,32 @@ from sohstationviewer.view.util.enums import LogType
 from sohstationviewer.conf.dbSettings import dbConf
 
 
+class DisplayTrackingInfoWrapper(QObject):
+    """
+    QObject wrapper for the function display_tracking_info. This class allows
+    display_tracking_info to be used from another thread.
+
+    Before PySide 6.8, this class was not required because functions are
+    always run on the main thread if they are used as slots. PySide 6.8
+    changed how signals and slots are implemented, however, which causes
+    these functions to instead be run in the same thread as the signal which
+    called them. This class is used to fix that by always setting its thread
+    affinity to the main thread, which means all of its slots will run in the
+    main thread.
+    """
+    def __init__(self):
+        super().__init__()
+        self.moveToThread(QApplication.instance().thread())
+
+    @Slot()
+    def display_tracking_info(self, tracking_box: QTextBrowser, text: str,
+                              type: LogType = LogType.INFO):
+        """
+        Wrapper for the function display_tracking_info.
+        """
+        display_tracking_info(tracking_box, text, type)
+
+
 def validate_file(path2file: Union[str, Path], file_name: str):
     """
     Check if fileName given is a file and not info file
diff --git a/sohstationviewer/model/data_loader.py b/sohstationviewer/model/data_loader.py
index 6e41c2591..7c420d0c0 100644
--- a/sohstationviewer/model/data_loader.py
+++ b/sohstationviewer/model/data_loader.py
@@ -9,7 +9,10 @@ from PySide6.QtCore import Qt
 from PySide6 import QtCore, QtWidgets
 
 from sohstationviewer.conf import constants
-from sohstationviewer.controller.util import display_tracking_info
+from sohstationviewer.controller.util import (
+    display_tracking_info,
+    DisplayTrackingInfoWrapper,
+)
 from sohstationviewer.model.general_data.general_data import (
     GeneralData, ThreadStopped, ProcessingDataError)
 from sohstationviewer.model.reftek_data.reftek_reader.log_file_reader import \
@@ -64,7 +67,11 @@ class DataLoaderWorker(QtCore.QObject):
         # the read. Since self.run runs in a background thread, we need to use
         # signal-slot mechanism to ensure that display_tracking_info runs in
         # the main thread.
-        self.notification.connect(display_tracking_info)
+        self.tracking_info_display = DisplayTrackingInfoWrapper()
+        self.notification.connect(
+            self.tracking_info_display.display_tracking_info,
+            type=Qt.ConnectionType.QueuedConnection
+        )
         self.end_msg = None
         # to change display tracking info to error when failed
         self.process_failed: bool = False
diff --git a/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py b/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py
index 2e37c46a5..9edc76df0 100644
--- a/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py
+++ b/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py
@@ -54,6 +54,10 @@ class MultiThreadedPlottingWidget(PlottingWidget):
         self.is_working = False
 
         self.finished.connect(self.stopped)
+        # TODO: Might not need to use a signal here. The only time this signal
+        #   emitted is from this class, and the class is always in the main
+        #   thread. The only things running in a different thread are the
+        #   PlottingChannelProcessor, and they don't use this signal
         self.notification.connect(
             lambda msg: display_tracking_info(self.tracking_box, msg))
 
diff --git a/tests/model/mseed_data/test_mseed.py b/tests/model/mseed_data/test_mseed.py
index 7c63df82a..c3cec5908 100644
--- a/tests/model/mseed_data/test_mseed.py
+++ b/tests/model/mseed_data/test_mseed.py
@@ -44,15 +44,18 @@ class TestMSeed(BaseTestCase):
         obj = MSeed(**args)
         self.assertEqual(list(obj.log_data.keys()), ['TEXT'])
         self.assertEqual(len(obj.log_data['TEXT']), 2)
-        self.assertEqual(
-            obj.log_data['TEXT'][0][:100],
+
+        print(obj.log_data)
+        trimmed_log_data = [obj.log_data['TEXT'][i][:100]
+                            for i in range(len(obj.log_data['TEXT']))]
+        expected_trimmed_log_1 = (
             '\n\n** STATE OF HEALTH: XX.KC01...D.2020.130'
             '\n2020-05-09 00:00:09.839 UTC: I(TimingThread): timing unce')
-
-        self.assertEqual(
-            obj.log_data['TEXT'][1][:100],
+        expected_trimmed_log_2 = (
             '\n\n** STATE OF HEALTH: XX.KC01...D.2020.129'
             '\n2020-05-08 22:55:45.390 UTC: I(Initializations): Firmware')
+        self.assertTrue(expected_trimmed_log_1 in trimmed_log_data)
+        self.assertTrue(expected_trimmed_log_2 in trimmed_log_data)
 
     def test_read_text_with_soh(self):
         # text get station from soh data with TXT as channel to add to log_data
@@ -69,15 +72,16 @@ class TestMSeed(BaseTestCase):
         self.assertEqual(len(obj.log_data['TEXT']), 0)
         self.assertEqual(list(obj.log_data['KC01'].keys()), ['TXT'])
         self.assertEqual(len(obj.log_data['KC01']['TXT']), 2)
-        self.assertEqual(
-            obj.log_data['KC01']['TXT'][0][:100],
+        trimmed_log_data = [obj.log_data['KC01']['TXT'][i][:100]
+                            for i in range(len(obj.log_data['KC01']['TXT']))]
+        expected_trimmed_log_1 = (
             '\n\n** STATE OF HEALTH: XX.KC01...D.2020.130'
             '\n2020-05-09 00:00:09.839 UTC: I(TimingThread): timing unce')
-
-        self.assertEqual(
-            obj.log_data['KC01']['TXT'][1][:100],
+        expected_trimmed_log_2 = (
             '\n\n** STATE OF HEALTH: XX.KC01...D.2020.129'
             '\n2020-05-08 22:55:45.390 UTC: I(Initializations): Firmware')
+        self.assertTrue(expected_trimmed_log_1 in trimmed_log_data)
+        self.assertTrue(expected_trimmed_log_2 in trimmed_log_data)
 
     def test_read_text_with_waveform(self):
         # text get station from waveform data with TXT as channel to add to
@@ -96,15 +100,16 @@ class TestMSeed(BaseTestCase):
         self.assertEqual(len(obj.log_data['TEXT']), 0)
         self.assertEqual(list(obj.log_data['KC01'].keys()), ['TXT'])
         self.assertEqual(len(obj.log_data['KC01']['TXT']), 2)
-        self.assertEqual(
-            obj.log_data['KC01']['TXT'][0][:100],
+        trimmed_log_data = [obj.log_data['KC01']['TXT'][i][:100]
+                            for i in range(len(obj.log_data['KC01']['TXT']))]
+        expected_trimmed_log_1 = (
             '\n\n** STATE OF HEALTH: XX.KC01...D.2020.130'
             '\n2020-05-09 00:00:09.839 UTC: I(TimingThread): timing unce')
-
-        self.assertEqual(
-            obj.log_data['KC01']['TXT'][1][:100],
+        expected_trimmed_log_2 = (
             '\n\n** STATE OF HEALTH: XX.KC01...D.2020.129'
             '\n2020-05-08 22:55:45.390 UTC: I(Initializations): Firmware')
+        self.assertTrue(expected_trimmed_log_1 in trimmed_log_data)
+        self.assertTrue(expected_trimmed_log_2 in trimmed_log_data)
 
     def test_read_ascii(self):
         # info is text wrapped in mseed format
-- 
GitLab