From a7641611a97132328fa9d315ded7edf835fa3530 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 12 Jul 2026 21:33:58 +0200 Subject: [PATCH] fix(tests): collect leaked QObjects between tests to end Py3.12 isolation flake Ursache: Mehrere GUI-Tests (test_smoke_launch, test_tray_preset_menu, test_settings_dialog, gui_app in test_state_machine) erzeugen BlitztextApp (selbst ein QObject) samt HotkeyWorker, QThread und Fenstern ohne Qt-Parent und lassen die Python-Referenz am Testende fallen. Diese Objekte haengen in Referenzzyklen (Signal/Slot-Verbindungen zeigen auf self), sodass der Refcount sie nicht abraeumt -- erst Pythons zyklischer GC loescht die C++-Objekte, zu nichtdeterministischem Zeitpunkt. Auf CPython 3.12 (CI-Matrix) faellt dieser GC-Lauf reproduzierbar mitten in HotkeyWorker.run() eines Folgetests; sip meldet dann den laufenden Worker als geloescht: RuntimeError: wrapped C/C++ object of type HotkeyWorker has been deleted (app/hotkey_service.py:307, self.workflow_triggered.emit) Betroffen: tests/test_state_machine.py::TestLeftAltEvents:: test_leftalt_keydown_triggers_toggle. Fix: Autouse-Fixture in tests/conftest.py ruft nach JEDEM Test gc.collect(). Der Abbau der geleakten Zyklen wird damit deterministisch an der Testgrenze erzwungen -- waehrend kein Worker laeuft. Im Folgetest bleibt fuer den GC nichts mehr einzusammeln, das mit einem laufenden run() kollidieren koennte. Reiner Test-Fix, kein Produktivcode. Bewusst NUR gc.collect(): eine erste, aggressivere Variante mit processEvents()+sendPostedEvents(DeferredDelete) loeschte noch von Tests referenzierte Widgets vorzeitig und provozierte C++-Doppel-Frees (Fatal Python error: Aborted). gc.collect() bricht nur echte, unerreichbare Zyklen auf und ist nebenwirkungsfrei. Verifikation (Py3.12-venv wie CI, QT_QPA_PLATFORM=offscreen, WHISPER_GUI_TESTS=1): - Einzeltest, test_state_machine.py, volle Suite: gruen (494 passed) - Zusaetzlich Py3.14: gruen; Non-GUI-Default: 424 passed / 70 skipped - Leak-Messung: geleakte BlitztextApp zwischen Tests 7 -> 2 (Peak) - compileall app + secret-scan (CI-Muster): sauber Risiken: gc.collect() nach jedem Test erhoeht die Suite-Laufzeit der Offscreen-GUI- Suite (~10s -> ~20s bei 494 Tests). Bewusst zugunsten deterministischer Isolation akzeptiert. Keine Testdeaktivierung, kein skip/xfail/retry. Rollback: git revert bzw. Entfernen der Fixture _collect_leaked_qobjects aus tests/conftest.py stellt den alten Stand her. --- tests/conftest.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 4b81fdb..cd56723 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,9 +10,13 @@ Tests, die das notify-Verhalten gezielt pruefen (``tests/test_features.py``), legen innerhalb ihres ``with patch(...)``-Blocks einen eigenen, verschachtelten Patch an und bleiben dadurch unveraendert gueltig. + +Zweiter Zweck: deterministischer Abbau geleakter Qt-Objekte zwischen Tests +(siehe ``_collect_leaked_qobjects``). """ from __future__ import annotations +import gc from unittest.mock import MagicMock, patch import pytest @@ -23,3 +27,34 @@ def _block_real_notifications(): """Unterbindet echte ``notify-send``-Aufrufe in der gesamten Testsuite.""" with patch("app.notify.subprocess.run", MagicMock()): yield + + +@pytest.fixture(autouse=True) +def _collect_leaked_qobjects(): + """Sammelt nach JEDEM Test geleakte, parentlose QObjects deterministisch ein. + + Root Cause der Python-3.12-Testisolation (``RuntimeError: wrapped C/C++ + object of type HotkeyWorker has been deleted``): Mehrere GUI-Tests erzeugen + ``BlitztextApp`` (selbst ein ``QObject``) samt ``HotkeyWorker``, ``QThread`` + und Fenstern ohne Qt-Parent und lassen die Python-Referenz am Testende + einfach fallen. Weil diese Objekte in Referenzzyklen haengen (Signal-/Slot- + Verbindungen zeigen auf ``self``), raeumt der Refcount sie nicht ab -- erst + Pythons zyklischer Garbage Collector loescht die zugehoerigen C++-Objekte, + und zwar zu einem nichtdeterministischen Zeitpunkt. Auf CPython 3.12 faellt + dieser GC-Lauf reproduzierbar mitten in ein ``HotkeyWorker.run()`` eines + Folgetests (``tests/test_state_machine.py::TestLeftAltEvents``); sip meldet + dann den gerade laufenden Worker als geloescht, sobald ``run()`` das naechste + Mal auf ``self`` (z. B. ``self.workflow_triggered``) zugreift. + + Ein explizites ``gc.collect()`` an der Testgrenze macht diesen Abbau + deterministisch: die geleakten Zyklen werden eingesammelt, WAEHREND kein + Worker laeuft. Im Folgetest bleibt fuer den GC nichts mehr einzusammeln, das + mit einem laufenden ``run()`` kollidieren koennte. + + Bewusst NUR ``gc.collect()``: kein ``processEvents``/``DeferredDelete``-Pump, + da dieser noch von Tests referenzierte Widgets vorzeitig loeschen und + C++-seitige Doppel-Frees ausloesen kann. ``gc.collect()`` bricht nur echte, + unerreichbare Zyklen auf und ist damit nebenwirkungsfrei. + """ + yield + gc.collect()