@@ -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
93138def sample_dat_files ():
94139 files = sorted (SAMPLE_DIR .glob ("*.dat" ))
0 commit comments