Skip to content

fix(tests): collect leaked QObjects between tests to end Py3.12 isolation flake#50

Merged
TimInTech merged 1 commit into
mainfrom
fix/hotkey-worker-test-isolation
Jul 12, 2026
Merged

fix(tests): collect leaked QObjects between tests to end Py3.12 isolation flake#50
TimInTech merged 1 commit into
mainfrom
fix/hotkey-worker-test-isolation

Conversation

@TimInTech

Copy link
Copy Markdown
Owner

Ursache

Mehrere GUI-Tests (test_smoke_launch, test_tray_preset_menu, test_settings_dialog,
gui_app in test_state_machine) erzeugen BlitztextApp samt HotkeyWorker, QThread
und Fenstern ohne Qt-Parent und lassen die Python-Referenz am Testende fallen. Diese
Objekte hängen in Referenzzyklen (Signal/Slot-Verbindungen zeigen auf self), sodass der
Refcount sie nicht abräumt — erst Pythons zyklischer GC löscht die C++-Objekte, zu
nichtdeterministischem Zeitpunkt.

Auf CPython 3.12 (CI-Matrix) fällt dieser GC-Lauf reproduzierbar mitten in
HotkeyWorker.run() eines Folgetests; sip meldet dann den laufenden Worker als gelöscht:

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 — während kein
Worker läuft. Im Folgetest bleibt für den GC nichts mehr einzusammeln, das mit einem
laufenden run() kollidieren könnte.

Reiner Test-Fix, kein Produktivcode. Bewusst NUR gc.collect(): eine erste,
aggressivere Variante mit processEvents()+sendPostedEvents(DeferredDelete) löschte 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.

Akzeptierter Laufzeit-Nebeneffekt

Das gc.collect() nach jedem Test kann die Testsuite messbar verlangsamen. Dieses
CI-/Entwicklungsrisiko wird bewusst akzeptiert, da App-Code, Installation und
Nutzerbetrieb nicht betroffen
sind — die Fixture greift ausschließlich in der Testumgebung.

Tests

Py3.12-venv wie CI (QT_QPA_PLATFORM=offscreen, WHISPER_GUI_TESTS=1):

  • Einzeltest, test_state_machine.py, volle Suite: grün (494 passed)
  • Zusätzlich Py3.14: grün; Non-GUI-Default: 424 passed / 70 skipped
  • Leak-Messung: geleakte BlitztextApp zwischen Tests 7 → 2 (Peak)
  • compileall app + secret-scan (CI-Muster): sauber

Rollback

Einzelner Commit, reine Testinfrastruktur. Rückgängig via git revert a764161
(bzw. Revert des Squash-Merge-Commits) — kein Produktivcode, keine Migration, kein State.

…tion 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 <diese Commit-SHA> bzw. Entfernen der Fixture
  _collect_leaked_qobjects aus tests/conftest.py stellt den alten Stand her.
@TimInTech TimInTech merged commit 3ea527e into main Jul 12, 2026
7 checks passed
@TimInTech TimInTech deleted the fix/hotkey-worker-test-isolation branch July 12, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant