pytest-qt icon indicating copy to clipboard operation
pytest-qt copied to clipboard

Leaking top level widgets

Open tlandschoff-scale opened this issue 1 year ago • 5 comments

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

tlandschoff-scale avatar Jun 26 '23 20:06 tlandschoff-scale