Repository: Joonalai/profiler-qgis-plugin
Pull request #8: Thread health checker
Author: login: Joonalai
Created: 2025-04-05T12:39:06Z | Merged: 2025-04-10T17:54:10Z

## Description <!-- markdownlint-disable-line MD041 -->

This PR adds a new meter for health checking of main thread:
- Another thread is used to poll the main thread and if there are any delays, anomaly will be reported
- Calibration for the meter threshold

## Type of change

- [ ] Bug fix
- [x] New feature
- [ ] Other
- [ ] Non-breaking change
- [x] Breaking change



Diff:
diff --git a/src/profiler_plugin/plugin.py b/src/profiler_plugin/plugin.py
index d5407eb..06cd5e9 100644
--- a/src/profiler_plugin/plugin.py
+++ b/src/profiler_plugin/plugin.py
@@ -94,10 +94,9 @@ def unload(self) -> None:
         self._teardown_loggers()
         self._teardown_loggers = lambda: None
 
-        if self._event_recorder:
-            self._event_recorder.cleanup()
         if self._profiler_panel_layout:
             self._profiler_panel_layout.removeWidget(self._profiler_extension)
         if self._profiler_extension:
+            self._profiler_extension.cleanup()
             self._profiler_extension.deleteLater()
         self._profiler_extension = None  # type:ignore[assignment]
diff --git a/src/profiler_plugin/ui/profiler_extension.py b/src/profiler_plugin/ui/profiler_extension.py
index ef9e9f8..7c77931 100644
--- a/src/profiler_plugin/ui/profiler_extension.py
+++ b/src/profiler_plugin/ui/profiler_extension.py
@@ -29,6 +29,7 @@
 from qgis_profiler.event_recorder import ProfilerEventRecorder
 from qgis_profiler.exceptions import ProfilerNotFoundError
 from qgis_profiler.meters.recovery_measurer import RecoveryMeasurer
+from qgis_profiler.meters.thread_health_checker import MainThreadHealthChecker
 from qgis_profiler.profiler import ProfilerWrapper
 from qgis_profiler.settings import ProfilerSettings
 
@@ -135,6 +136,11 @@ def _reset_meters(self) -> None:
         else:
             RecoveryMeasurer.get().enabled = False
 
+        if ProfilerSettings.thread_health_checker_enabled.get():
+            self._meters.append(MainThreadHealthChecker.get())
+        else:
+            MainThreadHealthChecker.get().enabled = False
+
         for meter in self._meters:
             meter.reset_parameters()
             meter.connect_to_profiler()
@@ -179,11 +185,17 @@ def _start_recording(self) -> None:
         if not self._event_recorder:
             return
         self._event_recorder.start_recording()
+        for meter in self._meters:
+            if meter.supports_continuous_measuring:
+                meter.start_measuring()
 
     def _stop_recording(self) -> None:
         if not self._event_recorder:
             return
         self._event_recorder.stop_recording()
+        for meter in self._meters:
+            if meter.supports_continuous_measuring:
+                meter.stop_measuring()
 
     def _clear_current_group(self) -> None:
         current_group = self.combo_box_group.currentText()
diff --git a/src/profiler_plugin/ui/settings_dialog.py b/src/profiler_plugin/ui/settings_dialog.py
index 2bb30bb..7347a85 100644
--- a/src/profiler_plugin/ui/settings_dialog.py
+++ b/src/profiler_plugin/ui/settings_dialog.py
@@ -45,6 +45,7 @@
 from qgis_plugin_tools.tools.settings import set_setting
 
 from qgis_profiler.meters.recovery_measurer import RecoveryMeasurer
+from qgis_profiler.meters.thread_health_checker import MainThreadHealthChecker
 from qgis_profiler.settings import ProfilerSettings, SettingCategory, WidgetType
 
 UI_CLASS: QWidget = load_ui_from_file(
@@ -76,6 +77,9 @@ def __init__(self, parent: Optional[QWidget] = None) -> None:
         self._groups: dict[SettingCategory, QgsCollapsibleGroupBox] = {}
 
         self._button_calibrate_recovery_meter = QPushButton(tr("Calibrate threshold"))
+        self._button_calibrate_thread_health_checker = QPushButton(
+            tr("Calibrate threshold")
+        )
 
         self._setup_plugin_settings()
         self._setup_logging_settings()
@@ -87,12 +91,17 @@ def __init__(self, parent: Optional[QWidget] = None) -> None:
         self._button_calibrate_recovery_meter.clicked.connect(
             self._calibrate_recovery_meter
         )
+        self._button_calibrate_thread_health_checker.clicked.connect(
+            self._calibrate_thread_health_checker
+        )
 
     def _setup_plugin_settings(self) -> None:
         for setting in ProfilerSettings:
             self._add_setting(setting)
         if group_box := self._groups.get(SettingCategory.RECOVERY_METER):
             group_box.layout().addWidget(self._button_calibrate_recovery_meter)
+        if group_box := self._groups.get(SettingCategory.THREAD_HEALTH_CHECKER_METER):
+            group_box.layout().addWidget(self._button_calibrate_thread_health_checker)
 
     def _reset_settings(self) -> None:
         # Clear all items from the settings layout
@@ -141,6 +150,7 @@ def _add_setting(self, setting: ProfilerSettings) -> None:
                 widget = QSpinBox()
             else:
                 widget = QDoubleSpinBox()
+                widget.setDecimals(3)
             if widget_config:
                 if widget_config.minimum is not None:
                     widget.setMinimum(widget_config.minimum)
@@ -184,10 +194,28 @@ def _calibrate_recovery_meter(self) -> None:
             times = list(filter(None, [meter.measure() for _ in range(10)]))
             safe_threshold_time = max(times) * CALIBRATION_COEFFICIENT
             LOGGER.info(
-                "Calibrated average recovery time: %s seconds", safe_threshold_time
+                "Calibrated recovery time threshold: %s seconds", safe_threshold_time
             )
             self._widgets[ProfilerSettings.recovery_threshold].setValue(
                 round(safe_threshold_time, 3)
             )
         finally:
             self._button_calibrate_recovery_meter.setEnabled(True)
+
+    def _calibrate_thread_health_checker(self) -> None:
+        self._button_calibrate_thread_health_checker.setEnabled(False)
+        try:
+            meter = MainThreadHealthChecker(
+                poll_interval_s=0.1,  # Don't want the calibration to take too long
+                threshold_s=100,  # Large number so no anomaly is detected
+            )
+            times = list(filter(None, [meter.measure() for _ in range(10)]))
+            safe_threshold_time = max(times) * CALIBRATION_COEFFICIENT
+            LOGGER.info(
+                "Calibrated poll time threshold: %s seconds", safe_threshold_time
+            )
+            self._widgets[ProfilerSettings.thread_health_checker_threshold].setValue(
+                round(safe_threshold_time, 3)
+            )
+        finally:
+            self._button_calibrate_thread_health_checker.setEnabled(True)
diff --git a/src/profiler_test_utils/decorator_utils.py b/src/profiler_test_utils/decorator_utils.py
index 224a7fd..86b9bcb 100644
--- a/src/profiler_test_utils/decorator_utils.py
+++ b/src/profiler_test_utils/decorator_utils.py
@@ -15,9 +15,14 @@
 #
 #  You should have received a copy of the GNU General Public License
 #  along with profiler-qgis-plugin. If not, see <https://www.gnu.org/licenses/>.
+import time
 
 from profiler_test_utils import utils
-from qgis_profiler.decorators import profile, profile_class
+from qgis_profiler.decorators import (
+    profile,
+    profile_class,
+)
+from qgis_profiler.meters.thread_health_checker import MainThreadHealthChecker
 
 EXTRA_GROUP = "New group"
 EXPECTED_TIME = 0.01
@@ -28,6 +33,10 @@ class DecoratorTester:
     def add(self, a: int, b: int) -> int:
         return _add(a, b)
 
+    @MainThreadHealthChecker.monitor()
+    def monitor_thread_health(self) -> None:
+        time.sleep(0.1)
+
     @profile(name="Add numbers")
     def add_with_name_kwarg(self, a: int, b: int) -> int:
         return _add(a, b)
diff --git a/src/qgis_profiler/meters/meter.py b/src/qgis_profiler/meters/meter.py
index 59834f9..acb25b3 100644
--- a/src/qgis_profiler/meters/meter.py
+++ b/src/qgis_profiler/meters/meter.py
@@ -20,6 +20,7 @@
 from contextlib import contextmanager, suppress
 from typing import Any, Callable, ClassVar, NamedTuple, Optional
 
+from qgis.core import QgsApplication
 from qgis.PyQt.QtCore import QObject, pyqtSignal, pyqtSlot
 
 from qgis_profiler import decorators
@@ -45,13 +46,17 @@ class MeterAnomaly(NamedTuple):
 
 
 class Meter(QObject):
+    """
+    Abstract base class for meters to detect anomalies in QGIS performance.
+    """
+
     __metaclass__ = abc.ABCMeta
 
     _short_name: ClassVar[str] = ""
 
     anomaly_detected = pyqtSignal(MeterAnomaly)
 
-    def __init__(self) -> None:
+    def __init__(self, supports_continuous_measurement: bool = False) -> None:  # noqa: FBT001, FBT002
         super().__init__(None)
         self._default_context = MeterContext(
             self.__class__.__name__, ProfilerSettings.meters_group.get()
@@ -59,6 +64,14 @@ def __init__(self) -> None:
         self._context_stack: list[MeterContext] = []
         self._enabled = True
         self._connected_to_profiler = False
+        self._supports_continuous_measuring: bool = supports_continuous_measurement
+        self._is_measuring: bool = False
+
+    def __del__(self) -> None:
+        """
+        Ensure cleanup when the object is garbage collected.
+        """
+        self.cleanup()
 
     @classmethod
     @abc.abstractmethod
@@ -68,18 +81,20 @@ def get(cls: type["Meter"]) -> "Meter":
         """
 
     @classmethod
-    def monitor(
+    def monitor(  # noqa: PLR0913
         cls,
         name: Optional[str] = None,
         group: Optional[str] = None,
         name_args: Optional[list[str]] = None,
         connect_to_profiler: bool = True,  # noqa: FBT001, FBT002
+        start_continuous_measuring: bool = True,  # noqa: FBT001, FBT002
         measure_after_call: bool = False,  # noqa: FBT001, FBT002
     ) -> Callable:
         """
         Decorator for monitoring the meter by setting the meter context
-        within a function call and by optionally measuring the meter
-        after the function call. If the meter is disabled, nothing is done.
+        within a function call and, by starting continuous measuring if supported
+        and by optionally measuring the meter after the function call.
+        If the meter is disabled, nothing is done.
 
         If you want to profile the anomalies found during the function call,
         connect to profiler using the `connect_to_profiler` argument.
@@ -92,8 +107,9 @@ def monitor(
         name. If specified, the context name will include these argument values.
         :param connect_to_profiler: Optional flag to connect to meter to a profiler
         if not yet connected.
+        :param start_continuous_measuring: Optional flag to start continuous measuring.
         :param measure_after_call: Optional flag to measure the meter after the function
-        call.
+        call. For some meters this might be expensive.
         :return: A callable decorator function that wraps the given function to
         set the meter context during the function call.
         """
@@ -116,10 +132,18 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
 
                 if connect_to_profiler and not meter.is_connected_to_profiler:
                     meter.connect_to_profiler()
+
                 with meter.context(context_name, group_name):
+                    if (
+                        start_continuous_measuring
+                        and meter.supports_continuous_measuring
+                        and not meter.is_measuring
+                    ):
+                        meter.start_measuring()
                     try:
                         return function(*args, **kwargs)
                     finally:
+                        QgsApplication.processEvents()
                         if measure_after_call:
                             meter.measure()
 
@@ -145,6 +169,10 @@ def is_connected_to_profiler(self) -> bool:
         """:return Whether the meter is connected to the profiler."""
         return self._connected_to_profiler
 
+    @property
+    def supports_continuous_measuring(self) -> bool:
+        return self._supports_continuous_measuring
+
     @property
     def enabled(self) -> bool:
         """:return Whether the meter is enabled."""
@@ -154,6 +182,10 @@ def enabled(self) -> bool:
     def enabled(self, enabled: bool) -> None:
         self._enabled = enabled
 
+    @property
+    def is_measuring(self) -> bool:
+        return self._is_measuring
+
     @contextmanager
     def context(self, name: str, group: str) -> Generator[MeterContext, None, None]:
         """Context manager for the meter in certain context."""
@@ -206,10 +238,31 @@ def measure(self) -> Optional[float]:
             return duration
         return None
 
+    def start_measuring(self) -> bool:
+        """
+        Starts the measurement process and reflects the status of whether
+        measurements have started successfully.
+
+        :return: A boolean indicating if the measurement
+            process was initiated successfully.
+        """
+        if self._supports_continuous_measuring:
+            self._is_measuring = True
+            return self._start_measuring()
+        return False
+
+    def stop_measuring(self) -> None:
+        """
+        Stops the continuous measurement process if applicable.
+        """
+        self._is_measuring = False
+        self._stop_measuring()
+
     def cleanup(self) -> None:
         """
-        Cleanup the meter.
+        Cleanup the meter and stop measuring if continuous measuring is supported.
         """
+        self.stop_measuring()
         with suppress(TypeError):
             self.anomaly_detected.disconnect(self._profile_anomaly)
         self._connected_to_profiler = False
@@ -240,3 +293,9 @@ def _measure(self) -> tuple[float, bool]:
     def _emit_anomaly(self, duration: float) -> None:
         """Emit anomaly_detected signal."""
         self.anomaly_detected.emit(MeterAnomaly(self.current_context, duration))
+
+    def _start_measuring(self) -> bool:
+        return False
+
+    def _stop_measuring(self) -> None:
+        pass
diff --git a/src/qgis_profiler/meters/thread_health_checker.py b/src/qgis_profiler/meters/thread_health_checker.py
new file mode 100644
index 0000000..9f0fa51
--- /dev/null
+++ b/src/qgis_profiler/meters/thread_health_checker.py
@@ -0,0 +1,222 @@
+#  Copyright (c) 2025 profiler-qgis-plugin contributors.
+#
+#
+#  This file is part of profiler-qgis-plugin.
+#
+#  profiler-qgis-plugin is free software: you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License as published
+#  by the Free Software Foundation, either version 3 of the License, or
+#  (at your option) any later version.
+#
+#  profiler-qgis-plugin is distributed in the hope that it will be
+#  useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+#  of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with profiler-qgis-plugin. If not, see <https://www.gnu.org/licenses/>.
+
+import logging
+import time
+import warnings
+from typing import Callable, Optional
+
+from qgis.core import QgsApplication
+from qgis.PyQt.QtCore import (
+    QElapsedTimer,
+    QEventLoop,
+    QObject,
+    QThread,
+    QTimer,
+    pyqtSignal,
+    pyqtSlot,
+)
+
+from qgis_profiler.meters.meter import Meter
+from qgis_profiler.settings import ProfilerSettings
+
+LOGGER = logging.getLogger(__name__)
+
+
+class ThreadPoller(QObject):
+    poll = pyqtSignal()
+
+    def __init__(self, poll_interval_ms: int) -> None:
+        super().__init__()
+        self._polling_active: bool = False
+        self._elapsed_timer = QElapsedTimer()
+        self._poll_interval = poll_interval_ms
+        self._timer: Optional[QTimer] = None
+        self._event_loop = QEventLoop(self)
+
+    @pyqtSlot()
+    def start(self) -> None:
+        """Start polling."""
+        LOGGER.debug("Starting thread poller")
+        self._run_polling()
+        self._setup_timer()
+        self._event_loop.exec()
+
+    @pyqtSlot()
+    def stop(self) -> None:
+        """Stop polling."""
+        LOGGER.debug("Stopping thread poller")
+        if self._timer:
+            self._timer.stop()
+        self._event_loop.exit()
+
+    def elapsed_ms_after_last_ping(self) -> int:
+        """Return elapsed milliseconds since last ping."""
+        return self._elapsed_timer.elapsed()
+
+    def set_poll_finished(self) -> None:
+        """Mark polling as finished."""
+        self._polling_active = False
+
+    @pyqtSlot()
+    def _run_polling(self) -> None:
+        """Emit poll and restart the elapsed timer."""
+        if self._polling_active:
+            return
+        self._polling_active = True
+        self._elapsed_timer.restart()
+        self.poll.emit()
+
+    def _setup_timer(self) -> None:
+        """Set up the QTimer for periodic polling."""
+        self._timer = QTimer(self)
+        self._timer.timeout.connect(self._run_polling)
+        self._timer.start(self._poll_interval)
+
+
+class MainThreadHealthChecker(Meter):
+    """
+    Monitors the health of the main application thread.
+
+    Provides mechanisms to measure delays between thread pings and
+    detect anomalies based on a defined threshold.
+    """
+
+    _short_name = "main_thread"
+    _instance: Optional["MainThreadHealthChecker"] = None
+
+    def __init__(self, poll_interval_s: float, threshold_s: float) -> None:
+        super().__init__(supports_continuous_measurement=True)
+        self._poll_interval_ms = poll_interval_s * 1000
+        self._threshold_ms = threshold_s * 1000
+
+        self._poller: Optional[ThreadPoller] = None
+        self._polling_thread: Optional[QThread] = None
+        self._last_delay_ms: int = 0
+        LOGGER.debug("Health checker parameters initialized: %s", self)
+
+    def __str__(self) -> str:
+        return (
+            f"MainThreadHealthChecker("
+            f"poll_interval_s={self._poll_interval_ms / 1000}, "
+            f"threshold_s={self._threshold_ms / 1000})"
+        )
+
+    @classmethod
+    def get(cls) -> "MainThreadHealthChecker":
+        if cls._instance is None:
+            cls._instance = MainThreadHealthChecker(
+                poll_interval_s=ProfilerSettings.thread_health_checker_poll_interval.get(),
+                threshold_s=ProfilerSettings.thread_health_checker_threshold.get(),
+            )
+            cls._instance.enabled = ProfilerSettings.thread_health_checker_enabled.get()
+        return cls._instance
+
+    @classmethod
+    def monitor(  # noqa: PLR0913
+        cls,
+        name: Optional[str] = None,
+        group: Optional[str] = None,
+        name_args: Optional[list[str]] = None,
+        connect_to_profiler: bool = True,  # noqa: FBT001, FBT002
+        start_continuous_measuring: bool = True,  # noqa: FBT001, FBT002
+        measure_after_call: bool = False,  # noqa: FBT001, FBT002
+    ) -> Callable:
+        if measure_after_call:
+            warnings.warn(
+                "measure_after_call is not recommended for MainThreadHealthChecker",
+                stacklevel=1,
+            )
+        return super().monitor(
+            name,
+            group,
+            name_args,
+            connect_to_profiler,
+            start_continuous_measuring,
+            measure_after_call,
+        )
+
+    def reset_parameters(self) -> None:
+        self._poll_interval_ms = (
+            ProfilerSettings.thread_health_checker_poll_interval.get() * 1000
+        )
+        self._threshold_ms = (
+            ProfilerSettings.thread_health_checker_threshold.get() * 1000
+        )
+        self.enabled = ProfilerSettings.thread_health_checker_enabled.get()
+        LOGGER.debug("Health checker parameters reset: %s", self)
+
+    def _start_measuring(self) -> bool:
+        LOGGER.debug("Starting health checking")
+        self._poller = ThreadPoller(int(self._poll_interval_ms))
+        self._poller.poll.connect(self._on_poll_event)
+
+        self._polling_thread = QThread(self)
+        self._poller.moveToThread(self._polling_thread)
+
+        self._polling_thread.finished.connect(self._poller.stop)
+        self._polling_thread.finished.connect(self._poller.deleteLater)
+        self._polling_thread.finished.connect(self._polling_thread.deleteLater)
+        self._polling_thread.started.connect(self._poller.start)
+        self._polling_thread.start()
+        return True
+
+    def _stop_measuring(self) -> None:
+        LOGGER.debug("Stopping health checking")
+        if self._poller:
+            self._poller.poll.disconnect(self._on_poll_event)
+        if self._polling_thread and self._polling_thread.isRunning():
+            self._polling_thread.quit()
+            self._polling_thread.wait()
+        self._polling_thread = None
+        self._poller = None
+
+    def _measure(self) -> tuple[float, bool]:
+        """
+        Measure the delay between thread poll and main thread response.
+
+        :return: the last delay in milliseconds and whether
+        it exceeded the threshold.
+        """
+        if self._poller:
+            return self._last_delay_ms / 1000, self._last_delay_ms > self._threshold_ms
+
+        self._last_delay_ms = 0
+        self.start_measuring()
+        t = time.time()
+        while (
+            self._last_delay_ms == 0
+            and time.time() - t
+            < ProfilerSettings.thread_health_checker_poll_interval.get() * 2
+        ):
+            QgsApplication.processEvents()
+        self.cleanup()
+        return self._last_delay_ms / 1000, self._last_delay_ms > self._threshold_ms
+
+    @pyqtSlot()
+    def _on_poll_event(self) -> None:
+        """Handle poll events."""
+        if not self._poller:
+            # Should not be possible
+            return
+        elapsed_ms = self._poller.elapsed_ms_after_last_ping()
+        if elapsed_ms > self._threshold_ms:
+            LOGGER.debug("Took too long %s ms", elapsed_ms)
+            self._emit_anomaly(round(elapsed_ms / 1000, 3))
+        self._poller.set_poll_finished()
+        self._last_delay_ms = elapsed_ms
diff --git a/src/qgis_profiler/settings.py b/src/qgis_profiler/settings.py
index e5f8311..e5a86f8 100644
--- a/src/qgis_profiler/settings.py
+++ b/src/qgis_profiler/settings.py
@@ -41,6 +41,7 @@ class WidgetType(enum.Enum):
 class SettingCategory(enum.Enum):
     PROFILING = tr("Profiling")
     RECOVERY_METER = tr("Recovery time measuring meter")
+    THREAD_HEALTH_CHECKER_METER = tr("Main thread health checker meter")
 
 
 @dataclass
@@ -131,6 +132,27 @@ class ProfilerSettings(enum.Enum):
         category=SettingCategory.RECOVERY_METER,
     )
 
+    # Health checker meter settings
+    thread_health_checker_enabled = Setting(
+        description=tr("Enable measure main thread health check meter"),
+        default=True,
+        category=SettingCategory.THREAD_HEALTH_CHECKER_METER,
+    )
+    thread_health_checker_poll_interval = Setting(
+        description=tr("A time in seconds between health check measurements"),
+        default=1.0,
+        category=SettingCategory.THREAD_HEALTH_CHECKER_METER,
+    )
+    thread_health_checker_threshold = Setting(
+        description=tr(
+            "A threshold in seconds of how fast main "
+            "thread should respond to health check"
+        ),
+        default=0.1,
+        category=SettingCategory.THREAD_HEALTH_CHECKER_METER,
+        widget_config=WidgetConfig(minimum=0.001, maximum=100.0, step=0.001),
+    )
+
     @staticmethod
     def reset() -> None:
         """
diff --git a/test/conftest.py b/test/conftest.py
index 1605ef5..c9cb5d0 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -25,6 +25,7 @@
 from profiler_test_utils.decorator_utils import ClassDecoratorTester, DecoratorTester
 from profiler_test_utils.utils import Dialog
 from qgis_profiler.meters.recovery_measurer import RecoveryMeasurer
+from qgis_profiler.meters.thread_health_checker import MainThreadHealthChecker
 from qgis_profiler.profiler import ProfilerWrapper
 from qgis_profiler.settings import ProfilerSettings
 
@@ -87,6 +88,13 @@ def mock_meter_recovery_measurer(mocker: "MockerFixture") -> MagicMock:
     return mock_meter
 
 
+@pytest.fixture
+def mock_thread_health_checker_meter(mocker: "MockerFixture") -> MagicMock:
+    mock_meter = mocker.MagicMock()
+    mocker.patch.object(MainThreadHealthChecker, "get", return_value=mock_meter)
+    return mock_meter
+
+
 @pytest.fixture
 def dialog(qtbot: "QtBot", qgis_parent: "QWidget") -> Dialog:
     dialog = Dialog(qgis_parent)
diff --git a/test/meters/test_thread_health_checker.py b/test/meters/test_thread_health_checker.py
new file mode 100644
index 0000000..531a627
--- /dev/null
+++ b/test/meters/test_thread_health_checker.py
@@ -0,0 +1,120 @@
+#  Copyright (c) 2025 profiler-qgis-plugin contributors.
+#
+#
+#  This file is part of profiler-qgis-plugin.
+#
+#  profiler-qgis-plugin is free software: you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License as published
+#  by the Free Software Foundation, either version 3 of the License, or
+#  (at your option) any later version.
+#
+#  profiler-qgis-plugin is distributed in the hope that it will be
+#  useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+#  of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with profiler-qgis-plugin. If not, see <https://www.gnu.org/licenses/>.
+
+import time
+from collections.abc import Iterator
+from typing import TYPE_CHECKING
+
+import pytest
+
+from qgis_profiler.meters.meter import MeterAnomaly, MeterContext
+from qgis_profiler.meters.thread_health_checker import (
+    MainThreadHealthChecker,
+)
+
+if TYPE_CHECKING:
+    from unittest.mock import MagicMock
+
+    from pytestqt.qtbot import QtBot
+
+    from profiler_test_utils.decorator_utils import DecoratorTester
+
+
+@pytest.fixture
+def thread_health_checker(
+    mock_profiler: "MagicMock",
+) -> Iterator[MainThreadHealthChecker]:
+    """Fixture to create and return a MainThreadChecker instance."""
+    checker = MainThreadHealthChecker.get()
+    checker._poll_interval_ms = 10
+    checker._threshold_ms = 90
+    yield checker
+    checker.cleanup()  # Ensure the checker is stopped after the test
+    assert checker.is_measuring is False
+
+
+def test_thread_poller_should_start_polling(
+    thread_health_checker: MainThreadHealthChecker, qtbot: "QtBot"
+):
+    assert not thread_health_checker.is_measuring
+
+    thread_health_checker.start_measuring()
+
+    assert thread_health_checker.is_measuring
+    assert thread_health_checker._poller
+    with qtbot.waitSignal(thread_health_checker._poller.poll, timeout=100):
+        """Poll should be called once"""
+
+    with qtbot.waitSignal(thread_health_checker._poller.poll, timeout=100):
+        """Wait again, timer should work"""
+
+
+def test_health_checker_should_emit_anomaly_on_thread_block(
+    thread_health_checker: MainThreadHealthChecker,
+    meters_group: str,
+    qtbot: "QtBot",
+):
+    # Arrange
+    thread_health_checker.start_measuring()
+
+    # Act
+    with qtbot.waitSignal(
+        thread_health_checker.anomaly_detected, timeout=200
+    ) as signal_blocker:
+        time.sleep(0.2)
+
+    # Assert
+    anomaly = signal_blocker.args[0]
+    assert isinstance(anomaly, MeterAnomaly)
+    assert anomaly.context == MeterContext(
+        "MainThreadHealthChecker (main_thread)", meters_group
+    )
+    assert anomaly.duration_seconds == pytest.approx(0.2, rel=0.1)
+
+    # single-shot measure should give the latest delay
+    assert (
+        thread_health_checker.measure() == thread_health_checker._last_delay_ms / 1000
+    )
+
+
+def test_health_checker_measure_should_poll_blocking(
+    thread_health_checker: MainThreadHealthChecker,
+    qtbot: "QtBot",
+):
+    duration = thread_health_checker.measure()
+    assert duration == pytest.approx(0.1, abs=1e-1)
+
+
+def test_monitor_main_thread_health_decorator_should_profile(
+    thread_health_checker: MainThreadHealthChecker,
+    default_group: str,
+    decorator_tester: "DecoratorTester",
+    mock_profiler: "MagicMock",
+    qtbot: "QtBot",
+):
+    # Act
+    with qtbot.waitSignal(thread_health_checker.anomaly_detected, timeout=200):
+        decorator_tester.monitor_thread_health()
+
+    # Assert
+    mock_profiler.add_record.assert_called_once_with(
+        "monitor_thread_health (main_thread)",
+        default_group,
+        pytest.approx(0.1, abs=1e-1),
+    )
+    assert thread_health_checker.is_measuring
diff --git a/test/plugin/test_profiler_extension.py b/test/plugin/test_profiler_extension.py
index 48e4639..9fa8a5e 100644
--- a/test/plugin/test_profiler_extension.py
+++ b/test/plugin/test_profiler_extension.py
@@ -96,6 +96,7 @@ def profiler_extension(
     mock_event_recorder: "MagicMock",
     mock_settings_dialog: "MagicMock",
     mock_meter_recovery_measurer: "MagicMock",
+    mock_thread_health_checker_meter: "MagicMock",
     _modify_mock_profiler: None,
     stub_profiler_panel: StubProfilerPanel,
 ) -> ProfilerExtension:
@@ -117,7 +118,7 @@ def test_profiler_extension_initialization(
     assert profiler_extension.combo_box_group.itemText(0) == "Group 1"
     assert profiler_extension.button_record.isEnabled()
     assert not profiler_extension.button_record.isChecked()
-    assert len(profiler_extension._meters) == 1
+    assert len(profiler_extension._meters) == 2
     # Not enabled for existing groups
     assert not profiler_extension.button_clear.isEnabled()
     assert not profiler_extension.button_save.isEnabled()
@@ -133,6 +134,7 @@ def test_toggle_recording(
     profiler_extension: ProfilerExtension,
     mock_event_recorder: "MagicMock",
     mock_profiler: "MagicMock",
+    mock_thread_health_checker_meter: "MagicMock",
     stub_profiler_panel: StubProfilerPanel,
     qtbot: "QtBot",
     subtests: "SubTests",
@@ -144,6 +146,7 @@ def test_toggle_recording(
         # Assert
         mock_event_recorder.start_recording.assert_called_once()
         mock_profiler.create_group.assert_called_once_with(TEST_GROUP)
+        mock_thread_health_checker_meter.start_measuring.assert_called_once()
         assert profiler_extension.button_record.isChecked()
         assert stub_profiler_panel.combo_box_group.currentText() == TEST_GROUP
 
@@ -153,6 +156,7 @@ def test_toggle_recording(
 
         # Assert
         mock_event_recorder.stop_recording.assert_called_once()
+        mock_thread_health_checker_meter.stop_measuring.assert_called_once()
         assert not profiler_extension.button_record.isChecked()
 
     assert profiler_extension.button_clear.isEnabled()
@@ -178,6 +182,7 @@ def test_button_settings_should_open_settings_dialog(
     profiler_extension: ProfilerExtension,
     mock_settings_dialog: "MagicMock",
     mock_meter_recovery_measurer: "MagicMock",
+    mock_thread_health_checker_meter: "MagicMock",
     qtbot: "QtBot",
 ) -> None:
     # Arrange
@@ -190,16 +195,20 @@ def test_button_settings_should_open_settings_dialog(
     mock_settings_dialog.exec.assert_called_once()
     mock_meter_recovery_measurer.cleanup.assert_called_once()
     mock_meter_recovery_measurer.reset_parameters.assert_called_once()
+    mock_thread_health_checker_meter.cleanup.assert_called_once()
+    mock_thread_health_checker_meter.reset_parameters.assert_called()
 
 
 def test_cleanup_should_clean_meters(
     profiler_extension: ProfilerExtension,
     mock_settings_dialog: "MagicMock",
     mock_meter_recovery_measurer: "MagicMock",
+    mock_thread_health_checker_meter: "MagicMock",
 ) -> None:
     # Act
     profiler_extension.cleanup()
 
     # Assert
     mock_meter_recovery_measurer.cleanup.assert_called_once()
+    mock_thread_health_checker_meter.cleanup.assert_called_once()
     assert profiler_extension._meters == []
diff --git a/test/plugin/test_settings_dialog.py b/test/plugin/test_settings_dialog.py
index d0e59e1..5c2ce60 100644
--- a/test/plugin/test_settings_dialog.py
+++ b/test/plugin/test_settings_dialog.py
@@ -32,6 +32,7 @@
 
 from profiler_plugin.ui.settings_dialog import SettingsDialog
 from qgis_profiler.meters.recovery_measurer import RecoveryMeasurer
+from qgis_profiler.meters.thread_health_checker import MainThreadHealthChecker
 from qgis_profiler.settings import ProfilerSettings, SettingCategory
 
 if TYPE_CHECKING:
@@ -73,7 +74,7 @@ def test_settings_dialog_initialization(settings_dialog: "SettingsDialog") -> No
     assert set(settings_dialog._widgets.keys()) == set(ProfilerSettings)
     assert set(settings_dialog._groups.keys()) == set(SettingCategory)
     assert settings_dialog._button_calibrate_recovery_meter.isEnabled()
-    # utils.wait(4000)
+    # utils.wait(10000)
 
 
 @pytest.mark.parametrize(
@@ -179,3 +180,29 @@ def test_calibrate_recovery_threshold(
     assert mock_measure.call_count == 10
     assert settings_dialog._widgets[ProfilerSettings.recovery_threshold].value() == 9.45
     assert settings_dialog._button_calibrate_recovery_meter.isEnabled()
+
+
+def test_calibrate_health_checker_threshold(
+    settings_dialog: "SettingsDialog",
+    qtbot: "QtBot",
+    mocker: MockerFixture,
+) -> None:
+    # Arrange
+    mock_measure = mocker.patch.object(
+        MainThreadHealthChecker, "measure", side_effect=map(float, range(10))
+    )
+
+    # Act
+    qtbot.mouseClick(
+        settings_dialog._button_calibrate_thread_health_checker, Qt.LeftButton
+    )
+
+    # Assert
+    assert mock_measure.call_count == 10
+    assert (
+        settings_dialog._widgets[
+            ProfilerSettings.thread_health_checker_threshold
+        ].value()
+        == 9.45
+    )
+    assert settings_dialog._button_calibrate_thread_health_checker.isEnabled()
diff --git a/whitelist.txt b/whitelist.txt
index f9f230b..93138bb 100644
--- a/whitelist.txt
+++ b/whitelist.txt
@@ -22,3 +22,5 @@ metaclass
 func
 varnames
 argcount
+poller
+stacklevel


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 12 out of 13 changed files in this pull request and generated 1 comment.

<details>
<summary>Files not reviewed (1)</summary>

* **whitelist.txt**: Language not supported
</details>



state: COMMENTED
author_association: NONE
submitted_at: 2025-04-05T12:39:41Z
user: login: Joonalai
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-05T12:41:48Z

Review comments:
diff_hunk: @@ -188,3 +198,19 @@ def _calibrate_recovery_meter(self) -> None:
             )
         finally:
             self._button_calibrate_recovery_meter.setEnabled(True)
+
+    def _calibrate_thread_health_checker(self) -> None:
+        self._button_calibrate_thread_health_checker.setEnabled(False)
+        try:
+            meter = MainThreadHealthChecker(
+                poll_interval_s=0.1,  # Don't want the calibration to take too long
+                threshold_s=100,  # Large number so no anomaly is detected
+            )
+            times = list(filter(None, [meter.measure() for _ in range(10)]))
path: src/profiler_plugin/ui/settings_dialog.py
user: login: Copilot
body: The measure() method of MainThreadHealthChecker returns a tuple (float, bool), but the calibration logic here expects a single float value. Consider extracting the first element of the tuple (e.g. using meter.measure()[0]) to compute the average time correctly.
```suggestion
            times = list(filter(None, [meter.measure()[0] for _ in range(10)]))
```
created_at: 2025-04-05T12:39:41Z
author_association: NONE
diff_hunk: @@ -188,3 +198,19 @@ def _calibrate_recovery_meter(self) -> None:
             )
         finally:
             self._button_calibrate_recovery_meter.setEnabled(True)
+
+    def _calibrate_thread_health_checker(self) -> None:
+        self._button_calibrate_thread_health_checker.setEnabled(False)
+        try:
+            meter = MainThreadHealthChecker(
+                poll_interval_s=0.1,  # Don't want the calibration to take too long
+                threshold_s=100,  # Large number so no anomaly is detected
+            )
+            times = list(filter(None, [meter.measure() for _ in range(10)]))
path: src/profiler_plugin/ui/settings_dialog.py
user: login: Joonalai
body: `def measure(self) -> Optional[float]`...
created_at: 2025-04-05T12:41:48Z
author_association: OWNER

PR comments:
user: login: codecov-commenter
created_at: 2025-04-05T12:40:56Z
body: :warning: Please install the !['codecov app svg image'](https://github.com/codecov/engineering-team/assets/152432831/e90313f4-9d3a-4b63-8b54-cfe14e7ec20d) to ensure uploads and comments are reliably processed by Codecov.

## [Codecov](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai) Report
Attention: Patch coverage is `80.45977%` with `34 lines` in your changes missing coverage. Please review.
> Project coverage is 85.28%. Comparing base [(`9063a2a`)](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/commit/9063a2a54db9bb48951b819c49178807da6434ca?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai) to head [(`e3c4fa4`)](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/commit/e3c4fa4dd0b40784ae4d8557d6b77994359deb93?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai).
> Report is 1 commits behind head on main.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai) | Patch % | Lines |
|---|---|---|
| [src/qgis\_profiler/meters/thread\_health\_checker.py](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?src=pr&el=tree&filepath=src%2Fqgis_profiler%2Fmeters%2Fthread_health_checker.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai#diff-c3JjL3FnaXNfcHJvZmlsZXIvbWV0ZXJzL3RocmVhZF9oZWFsdGhfY2hlY2tlci5weQ==) | 78.26% | [23 Missing and 2 partials :warning: ](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai) |
| [src/profiler\_plugin/ui/profiler\_extension.py](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?src=pr&el=tree&filepath=src%2Fprofiler_plugin%2Fui%2Fprofiler_extension.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai#diff-c3JjL3Byb2ZpbGVyX3BsdWdpbi91aS9wcm9maWxlcl9leHRlbnNpb24ucHk=) | 60.00% | [1 Missing and 3 partials :warning: ](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai) |
| [src/qgis\_profiler/meters/meter.py](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?src=pr&el=tree&filepath=src%2Fqgis_profiler%2Fmeters%2Fmeter.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai#diff-c3JjL3FnaXNfcHJvZmlsZXIvbWV0ZXJzL21ldGVyLnB5) | 89.65% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai) |
| [src/profiler\_plugin/plugin.py](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?src=pr&el=tree&filepath=src%2Fprofiler_plugin%2Fplugin.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai#diff-c3JjL3Byb2ZpbGVyX3BsdWdpbi9wbHVnaW4ucHk=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai) |
| [src/profiler\_plugin/ui/settings\_dialog.py](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?src=pr&el=tree&filepath=src%2Fprofiler_plugin%2Fui%2Fsettings_dialog.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai#diff-c3JjL3Byb2ZpbGVyX3BsdWdpbi91aS9zZXR0aW5nc19kaWFsb2cucHk=) | 93.33% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai) |

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main       #8      +/-   ##
==========================================
- Coverage   86.20%   85.28%   -0.92%     
==========================================
  Files          14       15       +1     
  Lines         761      931     +170     
  Branches       87      104      +17     
==========================================
+ Hits          656      794     +138     
- Misses         71       96      +25     
- Partials       34       41       +7     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Joonalai/profiler-qgis-plugin/pull/8?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Joonalai).

author_association: NONE
user: login: Joonalai
created_at: 2025-04-05T12:43:33Z
body: @komima, could you review this PR?
author_association: OWNER
user: login: Joonalai
created_at: 2025-04-05T16:20:54Z
body: I will do some modifications and commit all other code not related to new meter to main and add decorator function for this. EDIT: done.
author_association: OWNER
user: login: Joonalai
created_at: 2025-04-10T17:53:17Z
body: Also merging this already, @komima, feel free to comment here afterwards.
author_association: OWNER