|
| 1 | +import gc |
1 | 2 | import glob |
2 | 3 | import multiprocessing |
3 | 4 | import os |
|
21 | 22 | multiprocessing.set_start_method("spawn", force=True) |
22 | 23 | except RuntimeError: |
23 | 24 | pass # Already set |
| 25 | +from PyQt6 import sip |
| 26 | +from PyQt6.QtCore import QCoreApplication, QEvent, QObject, QThread |
24 | 27 | from PyQt6.QtSql import QSqlDatabase |
| 28 | +from PyQt6.QtWidgets import QApplication |
25 | 29 | from _pytest.fixtures import SubRequest |
26 | 30 |
|
27 | 31 | from buzz.db.dao.transcription_dao import TranscriptionDAO |
|
33 | 37 | from buzz.widgets.application import Application |
34 | 38 |
|
35 | 39 |
|
| 40 | +def pytest_sessionfinish(session, exitstatus): |
| 41 | + """Destroy Qt objects left over by the tests while the QApplication is alive. |
| 42 | +
|
| 43 | + At interpreter exit PyQt deletes every parentless QObject still owned by |
| 44 | + Python while walking sip's wrapper map. Deleting one of them can run Python |
| 45 | + code that creates or frees wrappers, which changes the map mid-walk and |
| 46 | + segfaults the run after all tests have passed. Deleting the leftovers here, |
| 47 | + from a snapshot, leaves nothing for that exit walk to do. |
| 48 | + """ |
| 49 | + app = QApplication.instance() |
| 50 | + if app is None: |
| 51 | + return |
| 52 | + |
| 53 | + for widget in app.topLevelWidgets(): |
| 54 | + widget.close() |
| 55 | + widget.deleteLater() |
| 56 | + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) |
| 57 | + gc.collect() |
| 58 | + |
| 59 | + leftovers = [] |
| 60 | + for obj in gc.get_objects(): |
| 61 | + try: |
| 62 | + if ( |
| 63 | + isinstance(obj, QObject) |
| 64 | + and obj is not app |
| 65 | + and not sip.isdeleted(obj) |
| 66 | + and sip.ispyowned(obj) |
| 67 | + and obj.parent() is None |
| 68 | + ): |
| 69 | + leftovers.append(obj) |
| 70 | + except RuntimeError: |
| 71 | + pass |
| 72 | + |
| 73 | + # Threads must be stopped before deletion, Qt aborts on deleting a running one |
| 74 | + for obj in leftovers: |
| 75 | + if isinstance(obj, QThread) and not sip.isdeleted(obj): |
| 76 | + obj.quit() |
| 77 | + obj.wait(5_000) |
| 78 | + |
| 79 | + main_thread = sip.unwrapinstance(QThread.currentThread()) |
| 80 | + for obj in leftovers: |
| 81 | + if sip.isdeleted(obj): |
| 82 | + continue |
| 83 | + # Objects living in a still running worker thread can't be safely deleted from here |
| 84 | + thread = obj.thread() |
| 85 | + if thread is not None and sip.unwrapinstance(thread) != main_thread and thread.isRunning(): |
| 86 | + continue |
| 87 | + if isinstance(obj, QThread) and obj.isRunning(): |
| 88 | + continue |
| 89 | + sip.delete(obj) |
| 90 | + leftovers.clear() |
| 91 | + gc.collect() |
| 92 | + |
| 93 | + |
36 | 94 | @pytest.fixture() |
37 | 95 | def db(qapp) -> QSqlDatabase: |
38 | 96 | # Qt's SQL module needs a QCoreApplication to be alive; opening a |
|
0 commit comments