Preventing segfaults in test suite that has Qt Tests
Motivation
When providing an GUI application one needs to select GUI backend. If application is Python and needs to work on all popular OSes1 the good choice is to use Qt. It is a cross-platform GUI toolkit that has good python bindings2.
However, the Qt objects require special care during testing. It this post I will describe my experience of writing such tests based on my work on PartSeg and napari.
The problem
As the Qt is C++ library it does not know about python memory management. This mean that if Qt keep reference to some widget it does not increase reference count of python object. This can lead to situation when python object is deleted, but there is still event pending in Qt event loop that reference this object.
When this happens Qt will try to access deleted object, leading to access to unallocated memory (segfault). This is very hard to debug because segfault can occur in any subsequent test, making it unclear what the cause is.
The error messages vary across different operating systems:
- Windows
Windows fatal exception: access violation
- Linux
Segmentation fault (core dumped)
orFatal Python error: Aborted
- macOS
Fatal Python error: Segmentation fault
Moreover, this behavior is non-deterministic and may be not reproducible locally. One of observed source of difference is that the CI runs on server version of OS. I have encountered cases when I cannot reproduce the error on my development machine, but I could do this on my server.
What is segfault
A segfault occurs when a program tries to access memory that is not allocated to it. In such situations, the OS will kill the program to prevent corruption. For security reasons, the OS does not allow handling this error, as it may be caused by malicious code.
An even worse scenario is when the addressed memory is allocated for a different object than the original pointer3 was pointing to. This can lead to modifying a different object in an unpredictable way, causing the test or program to fail unexpectedly.
How to prevent it
This section is based on my experience and may not be complete.
Ensure that all qt widgets are scheduled for deletion
All Qt objects have a deleteLater
method that schedules the object for deletion.
This allows for the safe deletion of the object, ensuring that all pending events are processed.
If you use some widget in test, that is not child of any other widget,
you should call deleteLater
on it before the test end.
It is also good practice to ensure that the widget is hidden before deletion.
So if your test requires showing the widget (e.g. screenshot test) you should hide it before deletion.
When using pytest
for testing I suggest using the pytest-qt
plugin.
This plugin provides qtbot
fixture that can be used to interact with Qt objects.
It also provides a qtbot.add_widget
method that ensures deleteLater
is called on the widget when the test ends.
If your widget requires special teardown you can use before_close_func
argument of add_widget
method.
Ensure all timers, animations or threads are stopped
I have observed that not stopping QTimer
,
QPropertyAnimation
, QThread
or QThreadPool
can lead to segfault.
It may also lead to some other problems with test.
So if you use any of this objects in test you should ensure that they are stopped before test ends.
Use the smallest possible widgets for tests
The process of setup and teardown of complex widgets is complex, time-consuming and may contain bugs that are hard to detect. So if the test purpose is to check behavior of some widget it is better to only create this widget, not the whole window that contains it.
How to debug and prevent
In this section I will describe my tricks used to debug and prevent segfaults. However, it may not fit to all projects.
Run test under gdb
or lldb
If you could reproduce the segfault locally you can run the test under gdb
or lldb
.
Then you could go through the stack trace and see what is the cause of segfault.
There is also option to increase interpolation between gdb
and python https://docs.python.org/3/howto/gdb_helpers.html.
You may also build qt in debug mode and compile your python wrapper against it. It will provide more information in stack trace, but is complex and time-consuming.
Prevent QThread
and QTimer
from running
Commonly, the test do not need to use threads. However, it may happen that integration test may trigger some thread.
It may be a good idea to fail the test if there is call of QThread.start
method. I use following pytest fixture to do this:
@pytest.fixture(autouse=True)
def _block_threads(monkeypatch, request):
if "enablethread" in request.keywords:
return
from pytestqt.qt_compat import qt_api
from qtpy.QtCore import QThread, QTimer
old_start = QTimer.start
class OldTimer(QTimer):
def start(self, time=None):
if time is not None:
old_start(self, time)
else:
old_start(self)
def not_start(self):
raise RuntimeError("Thread should not be used in test")
monkeypatch.setattr(QTimer, "start", not_start)
monkeypatch.setattr(QThread, "start", not_start)
monkeypatch.setattr(qt_api.QtCore, "QTimer", OldTimer)
As you may see, there is option to allow thread usage by using custom enablethread
marker.
The documentation for declaring custom markers is available in pytest documentation.
As documentation do not provide example for pyproject.toml
I will provide example how to do this:
You may also spot monkeypatch.setattr(qt_api.QtCore, "QTimer", OldTimer)
line. It is added because QTimer
is used internally in pytest-qt
plugin for qtbot.wait*
methods.
In similar way you can block usage of QPropertyAnimation
.
This approach raises exception when non-allowed method is called, so it is easy to prevent unwanted usage of threads. However it may increase hardness of contributing to project, as it is custom behavior, that potential contributor may not expect.
Find active timers after test end
In napari project we have developed a pytest
fixtures that checks if there are any active QTimers
, QThreads
, QThreadPool
and QPropertyAnimation
after test end.
This method is not perfect as it may not be triggered at every test suite run. So problematic code may be detected after log time.
@pytest.fixture(auto_use=True)
def dangling_qthreads(monkeypatch, qtbot, request):
from qtpy.QtCore import QThread
base_start = QThread.start
thread_dict = WeakKeyDictionary()
def start_with_save_reference(self, priority=QThread.InheritPriority):
"""Thread start function with logs to detect hanging threads.
Saves a weak reference to the thread and detects hanging threads,
as well as where the threads were started.
"""
thread_dict[self] = _get_calling_place()
base_start(self, priority)
monkeypatch.setattr(QThread, 'start', start_with_save_reference)
yield
dangling_threads_li = []
for thread, calling in thread_dict.items():
try:
if thread.isRunning():
dangling_threads_li.append((thread, calling))
except RuntimeError as e:
if (
'wrapped C/C++ object of type' not in e.args[0]
and 'Internal C++ object' not in e.args[0]
):
# object was deleted
raise
for thread, _ in dangling_threads_li:
with suppress(RuntimeError):
thread.quit()
qtbot.waitUntil(thread.isFinished, timeout=2000)
long_desc = (
'If you see this error, it means that a QThread was started in a test '
'but not terminated. This can cause segfaults in the test suite. '
'Please use the `qtbot` fixture to wait for the thread to finish. '
)
if len(dangling_threads_li) > 1:
long_desc += ' The QThreads were started in:\n'
else:
long_desc += ' The QThread was started in:\n'
assert not dangling_threads_li, long_desc + '\n'.join(
x[1] for x in dangling_threads_li
)
It is simplified version of napari fixture. You may see full versions in napari contest
For other problematic objects you can use similar approach. There are proper fixtures in same conftest.py
file.
Detect leaked widgets
TBA https://github.com/napari/napari/pull/7251
Bonus tip
Your tests are hanging, but any above solution did not help. What to do?
One of the possible reason is that your code is created some nested event loop by opening QDialog
or QMessageBox
using exec
method.
To get error message instead of hanging test I use following pytest fixture:
import pytest
@pytest.fixture(autouse=True)
def _block_message_box(monkeypatch, request):
def raise_on_call(*_, **__):
raise RuntimeError("exec_ call") # pragma: no cover
monkeypatch.setattr(QMessageBox, "exec_", raise_on_call)
monkeypatch.setattr(QMessageBox, "exec", raise_on_call)
monkeypatch.setattr(QMessageBox, "critical", raise_on_call)
monkeypatch.setattr(QMessageBox, "information", raise_on_call)
monkeypatch.setattr(QMessageBox, "question", raise_on_call)
monkeypatch.setattr(QMessageBox, "warning", raise_on_call)
monkeypatch.setattr("PartSeg.common_gui.error_report.QMessageFromException.exec_", raise_on_call)
monkeypatch.setattr(QInputDialog, "getText", raise_on_call)
if "enabledialog" not in request.keywords:
monkeypatch.setattr(QDialog, "exec_", raise_on_call)
monkeypatch.setattr(QDialog, "exec", raise_on_call)
As you can see I block multiple methods that can create nested event loop.
In some test I need to allow calling exec
method of QDialog
,
so I have defined enabledialog
marker that I can use to allow this call.
-
This includes Windows, macOS, and various distributions of Linux. ↩
-
PyQt5, PySide2 for Qt5, PyQT6, PySide6 for Qt6. ↩
-
https://en.wikipedia.org/wiki/Pointer_(computer_programming) ↩