Skip to content

Commit 932591c

Browse files
Merge pull request #6 from SPMQT-Lab/harden-gui-test-qt-drain
Drain Qt workers and deferred deletions between tests
2 parents 7d7b198 + 04a350b commit 932591c

1 file changed

Lines changed: 45 additions & 0 deletions

File tree

tests/conftest.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,51 @@ def pytest_collection_modifyitems(config, items):
8989
item.add_marker(marker)
9090

9191

92+
@pytest.fixture(autouse=True)
93+
def _drain_qt_between_tests():
94+
"""Retire Qt background workers and deferred deletions after each test.
95+
96+
The GUI tests start real ``QThreadPool`` workers (e.g. ``ViewerLoader``) and
97+
rely on ``deleteLater()`` for widget cleanup, but they never run a Qt event
98+
loop, so neither finished-worker signals nor deferred deletions are flushed
99+
at the test boundary. They then leak into the *next* test and are processed
100+
at a nondeterministic moment — delivering a queued ``loaded`` signal to, or
101+
running ``~QObject`` on, a half-torn-down dialog. That is the intermittent
102+
offscreen-Qt SIGSEGV (it migrates between tests and passes on re-run).
103+
104+
Draining here makes each GUI test start from a clean slate: wait for the
105+
global pool to finish (so no worker thread can still emit into a widget the
106+
next test tears down), deliver the now-posted cross-thread signals to their
107+
still-live receivers, then run the deferred deletions and settle.
108+
109+
Costs nothing when Qt was never imported (pure non-GUI sessions) or when no
110+
``QApplication`` exists yet.
111+
"""
112+
yield
113+
114+
qtwidgets = sys.modules.get("PySide6.QtWidgets")
115+
if qtwidgets is None:
116+
return
117+
app = qtwidgets.QApplication.instance()
118+
if app is None:
119+
return
120+
121+
from PySide6.QtCore import QEvent, QThreadPool
122+
123+
# 1. Let in-flight workers finish so their loaded()/failed() events are all
124+
# posted to the main thread before we flush. Returns immediately when the
125+
# pool is idle; the timeout is a safety bound that should never be hit.
126+
QThreadPool.globalInstance().waitForDone(5000)
127+
# 2. Deliver queued cross-thread signals to receivers that are still alive
128+
# (token-guarded slots drop stale content harmlessly).
129+
app.processEvents()
130+
# 3. Now actually destroy everything deleteLater()'d; Qt drops each object's
131+
# pending posted events as it runs the destructor.
132+
app.sendPostedEvents(None, QEvent.DeferredDelete)
133+
# 4. Settle anything the deletions themselves posted.
134+
app.processEvents()
135+
136+
92137
@pytest.fixture
93138
def sample_dat_files():
94139
files = sorted(SAMPLE_DIR.glob("*.dat"))

0 commit comments

Comments
 (0)