pytest-qt
pytest-qt copied to clipboard
Leaking top level widgets
Hi *,
our pipeline broke lately, running in to segfaults. After a while of searching, it appears that some widgets live on until the the test session concludes. Somehow, during interpreter shutdown, this leads into updating dead QAbstractItemModel instances, which causes the crash.
It is still unclear to me how this unfolds, but the basics are reproducible in pytest-qt at 3e593f25231c1a745320c406d7a025f21a668ec0. Look at this (new) test case (tests/test_widget_leak.py
):
import pytest
from pytestqt.qt_compat import qt_api
@pytest.mark.parametrize("n", range(42))
def test_widget_leak(qapp, n):
widget = qt_api.QtWidgets.QWidget()
widget.show()
qapp.processEvents()
assert len(qapp.topLevelWidgets()) == 1
I get a successful run exercising only this new test case:
$ tox -e py38-pyqt5 -- tests/test_widget_leak.py
...
========== 42 passed in 0.12s ==========
py38-pyqt5: OK (5.39=setup[5.00]+cmd[0.40] seconds)
congratulations :) (5.52 seconds)
But running some existing tests cases first, this fails because there are additional top level widgets. Also, the newly created widgets are kept now:
$ tox -e py38-pyqt5 -- tests/test_basics.py::test_stop tests/test_widget_leak.py
...
========== short test summary info ==========
FAILED tests/test_widget_leak.py::test_widget_leak[0] - assert 2 == 1
FAILED tests/test_widget_leak.py::test_widget_leak[1] - assert 3 == 1
FAILED tests/test_widget_leak.py::test_widget_leak[2] - assert 4 == 1
...
========== 42 failed, 1 passed in 0.28s ==========
I failed to directly and explicitly delete/destroy those top level widgets (there is no usable delete
/destroy
method exposed in Python). The following work around seems to get rid of extra top levels:
diff --git a/src/pytestqt/plugin.py b/src/pytestqt/plugin.py
index fab2c72..fca66b7 100644
--- a/src/pytestqt/plugin.py
+++ b/src/pytestqt/plugin.py
@@ -50,9 +50,15 @@ def qapp_cls():
"""
return qt_api.QtWidgets.QApplication
[email protected]
+def qapp(qapp_session):
+ try:
+ yield qapp_session
+ finally:
+ _delete_toplevels(qapp_session)
@pytest.fixture(scope="session")
-def qapp(qapp_args, qapp_cls, pytestconfig):
+def qapp_session(qapp_args, qapp_cls, pytestconfig):
"""
Fixture that instantiates the QApplication instance that will be used by
the tests.
@@ -76,6 +82,25 @@ def qapp(qapp_args, qapp_cls, pytestconfig):
return app
+def _delete_toplevels(app):
+ QTimer = qt_api.QtCore.QTimer
+
+ def delete_core():
+ top_levels = app.topLevelWidgets()
+ if top_levels:
+ top = top_levels.pop()
+ if top:
+ top.deleteLater()
+ QTimer.singleShot(0, delete_core)
+ else:
+ loop.exit(0)
+
+ QTimer.singleShot(0, delete_core)
+ loop = qt_api.QtCore.QEventLoop()
+ loop.exec_()
+ assert not app.topLevelWidgets()
+
+
# holds a global QApplication instance created in the qapp fixture; keeping
# this reference alive avoids it being garbage collected too early
_qapp_instance = None
I have no idea though if this has any bad side effects. Also, I am not sure what causes the survival of the widgets. Any insights appreciated.
Greetings, Torsten