The hypothesized mechanism — Python's cyclic garbage collector running on a non-GUI thread and executing the C++ destructor of a Python-owned QObject there, producing a cross-thread use-after-free — is well-documented prior art dating to August 2011, affecting both PyQt and PySide continuously since. It is structural to the CPython + Qt combination, not specific to PySide6, Qt 6, Wayland, or Python 3.14.
The established community remedy for the last decade-plus: disable automatic GC and run gc.collect() periodically on the GUI thread only (the Kovid Goyal workaround, packaged as qtpygc, adopted by calibre, napari, pyqtgraph).
An August 2011 PyQt mailing-list thread ("subtle bug in PyQt in combination with Python garbage collector", Riverbank pipermail archive) describes the mechanism exactly as this project inferred it: a QObject constructed in one thread participates in a reference cycle; it dies only when the collector runs; if the collector runs on a different thread, the QObject is destroyed on that thread; destruction sends events to the parent on the wrong thread; the result is corruption and/or segfaults. Observed on Qt 4.7.2 / PyQt 4.8.3 / sip 4.12.1, with the reporter noting "PySide suffers from the same behavior." The attached backtrace passes through QCoreApplicationPrivate::checkReceiverThread — with a development (assertion-enabled) Qt build the assertion fires visibly instead of silently corrupting.
| Where | When | Versions | Substance |
|---|---|---|---|
| napari issue #1029 | 2020 | PyQt/PySide, Qt 5 era | "Python garbage collector can trigger in any thread"; deallocations count as thread-unsafe interactions with Qt objects; proposed disabling default GC and collecting periodically on the GUI thread. |
| PYSIDE-1919 (bugreports.qt.io) | May 2022 | PySide6 6.3.0, Python 3.10 | Filed by a pyqtgraph developer: segfault when the collector cleans up a QObject with a connected signal. Notably did not reproduce on Python 3.9/3.8 — Python-version sensitivity of the trigger rate is itself prior art. |
| tschoenfelder.de blog, "Incompatibility of Qt with Python's garbage collector" | 2024 | Current Qt + Python at time of writing | Concludes the issue persists, appears fundamental and unfixable in general, mitigations exist; notes pyqtgraph ships an implementation of the workaround without advertising it, calling the whole thing "an open secret" among Qt-on-Python developers. |
| qtpygc (PyPI) | ongoing | PyQt and PySide | Packaged implementation of the workaround; README states plainly that both PyQt and PySide "have a longstanding bug that may never be fixed," and documents its own residual gap (see §3). |
| Qt Forum topic 164002 | Jan 2026 | PySide6 6.10.1, Python 3.14, Wayland | Related but distinct: segfault at shutdown from asynchronous Python-side destruction ordering vs. C++ expectations (QWindowContainer accessing a destroyed QWindow). Evidence that Python-vs-C++ destruction-order mismatches remain live in the 6.10.x / 3.14 era. |
The PySide6 release notes (doc.qt.io/qtforpython-6, release notes page) list: "PYSIDE-3288 It is now possible to defer deletion of QObjects in case they get garbage-collected by a thread different from their owner thread and thus ensure the correct thread affinity." The entry appears in the same release section as "PYSIDE-3147 Python 3.14 is now supported" — consistent with the 6.10/6.11 timeframe, but the exact version boundary was not confirmed. Open questions to resolve directly from the ticket (bugreports.qt.io/browse/PYSIDE-3288) and its commit:
deleteLater()-equivalent semantics (which would make the project's deterministic worker-thread test pass by design once enabled).[unverified] — nothing above is a project fact until the behavior is measured with the feature identified and toggled on this machine.
Python 3.14 shipped an incremental, two-generation garbage collector (from the Faster CPython project), which was subsequently reverted in 3.14.5 after real-world memory-pressure regressions; the same change had previously been attempted and reverted in 3.13. Implication for this project: the timing and frequency of collections differ across 3.14.x patch releases. This does not create the crash, but it plausibly moves the abort rate on identical source trees — record the exact 3.14.x micro-version alongside every rate measurement. Separately, a pytest-tracker issue (pytest #14500, 2026) reports a Windows-only fatal GC-time crash on Python 3.14.5 with the identical Garbage-collecting frame-0 signature — different platform and likely different mechanism, but confirmation that the signature itself only means "died inside a collection," nothing more.
The established pattern (12+ years of practice): disable automatic garbage collection and run gc.collect() periodically on the main GUI thread — the workaround suggested by Kovid Goyal on the PyQt mailing list, packaged as qtpygc, and used in practice by calibre, napari, and pyqtgraph (which ships its own implementation). Documented residual gap: QObjects created on non-GUI threads can still be destroyed on the wrong thread when the GUI thread collects; those must be destroyed explicitly, e.g. via deleteLater().
Qt's own sanctioned rule (QObject destructor documentation): a QObject must not be deleted directly from a thread other than the one that owns it; deleteLater() is the safe cross-thread path, deleting the object after pending events are delivered.
Parenting so C++ owns the lifetime is standard and helps, but does not cover the case at hand: an object whose last reference is a Python cycle is by construction one where Python-side ownership (Shiboken.ownedByPython true) decides the destruction thread.
For this test suite specifically, the cheap form of the workaround is a session fixture:
# conftest.py
import gc, pytest
@pytest.fixture(autouse=True, scope="session")
def _gc_on_main_thread_only():
gc.disable()
yield
gc.enable()
@pytest.fixture(autouse=True)
def _collect_between_tests():
yield
gc.collect() # runs on the pytest main thread
Cost: cycles live until the next between-test collection (bounded memory growth per test — usually acceptable at this suite size). Note also that the project's deterministic test — asserting that a worker-thread gc.collect() destroys no widget — asserts a guarantee CPython + Shiboken have never made; pre-PYSIDE-3288 that assertion is expected to fail whenever a Python-owned widget is in the collectable garbage.
| Tool | Packages / invocation | Notes |
|---|---|---|
| gdb + Python backtrace from core | gdb, python3.14-dbg (or python3-dbg); the python3.14-gdb.py auto-load script provides py-bt, py-list, py-locals | Works against the venv's release interpreter for C frames; full py-bt fidelity wants the dbg interpreter. Enable cores: ulimit -c unlimited; check coredumpctl on systemd. |
| PySide6 debug symbols | — | Pip wheels ship stripped release binaries; no separate debug symbols are published for the wheel. The system PySide6 6.10.2 (distro-packaged) can get -dbgsym ddebs from Ubuntu's dbgsym repository — the practical argument for reproducing on the system package when a symbolized C++ stack is needed. |
| GC snapshot at collection time | gc.set_debug(gc.DEBUG_SAVEALL) + a gc.callbacks hook | Log, before the destructor runs: current thread, and the count/identity of QObject instances present in the about-to-be-collected garbage. Cheap and targeted. |
| Interpreter hardening | PYTHONMALLOC=debug, python -X dev -X faulthandler -X tracemalloc | All cheap; stack them. -X dev also surfaces unraisable exceptions and ResourceWarnings. |
| Qt-side early assertion | QT_FATAL_WARNINGS=1; QT_LOGGING_RULES for finer control | The 2011 stack passes checkReceiverThread; fatal warnings convert the cross-thread event delivery into an immediate, informative abort earlier and closer to the cause than the eventual use-after-free. Probably the best rate-of-information-per-effort item here. |
| Shiboken diagnostics | Shiboken.dump(obj), Shiboken.ownedByPython(obj), Shiboken.isValid(obj) | Per-object; no documented "dump all wrapped objects" API was found (see §5.2 for the workaround). |
| ASan / Valgrind | — | Valgrind works against the wheel but is prohibitively slow for a 4,280-test suite; ASan effectively requires rebuilding CPython and PySide6 instrumented. Not worth it before PYSIDE-3288 is read and tried. |
ast.parse significant?Almost certainly a bystander. CPython's collector fires on allocation thresholds, so it fires inside whatever code happens to be allocating heavily — the frame-0 site identifies the trigger, not the victim. The victim is whatever Python-owned QObject sat in the collectable garbage at that moment. This is exactly why the site moves with the instrument (tracing and coverage change allocation patterns and thresholds) while the Garbage-collecting signature never varies — and it is consistent with every prior-art account above, none of which found the crash site meaningful.
No public Shiboken API to walk all wrapped objects was found documented. The practical idiom:
import gc
from PySide6.QtCore import QObject
import Shiboken
at_risk = [o for o in gc.get_objects()
if isinstance(o, QObject) and Shiboken.ownedByPython(o)]
gc.get_objects() sees everything the collector tracks — which is precisely the at-risk population: objects that die by pure refcounting die deterministically at the del/scope-exit site on a known thread, while collector-tracked cycle members are the ones whose destruction thread is nondeterministic. QApplication.topLevelWidgets() is indeed insufficient; this enumeration covers children and non-widget QObjects alike. Pair it with the §4 gc.callbacks hook to capture the set at the instant of each collection.
With 4 aborts in 6 untraced runs, the per-run clean probability under the null (nothing fixed) is ≈ 1/3. N consecutive clean runs therefore has probability (1/3)N of being luck:
| Clean runs (N) | P(luck) under null |
|---|---|
| 3 | ≈ 3.7% |
| 5 | ≈ 0.41% |
| 8 | ≈ 0.015% |
| 10 | ≈ 0.0017% |
Ten clean untraced full-suite runs is strong statistical evidence. The better proof is mechanistic: the deterministic worker-thread gc.collect() test flipping from crash to pass under the candidate fix is worth more than any N, because it removes the rate question entirely.
ast.parse during collection." (Search terms used: PYSIDE garbage collector QObject wrong thread crash; PySide6 SIGABRT Garbage-collecting faulthandler.) Expected, per §5.1 — the site is incidental.ownedByPython or general finalisation semantics beyond the PYSIDE-3288 deferred-deletion feature itself.