Research Findings — PySide6/Shiboken Fatal Aborts During Garbage Collection

Prepared 27 August 2026 · Scope: prior art, supported mitigations, tooling, and diagnostic technique for intermittent SIGABRT with the Garbage-collecting faulthandler signature in a PySide6 6.10.2 / 6.11.1 + Python 3.14 pytest suite (~4,280 tests) on Ubuntu 26.04 / Wayland / QT_QPA_PLATFORM=offscreen.
All external findings are [unverified] until measured on the target machine.

1. Executive summary

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.

Headline finding: the official PySide6 release notes 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." This is a first-party acknowledgment of exactly this mechanism and a shipped mitigation. The exact version it landed in relative to 6.10.2 / 6.11.1, and how it is enabled ("now possible" suggests opt-in, not default), could not be pinned from the notes page alone — reading the PYSIDE-3288 ticket and commit is the single highest-value next step.

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).

2. Q1 — Prior art

2.1 The 2011 origin thread

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.

2.2 Independent rediscoveries

WhereWhenVersionsSubstance
napari issue #10292020PyQt/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 2022PySide6 6.3.0, Python 3.10Filed 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"2024Current Qt + Python at time of writingConcludes 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)ongoingPyQt and PySidePackaged 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 164002Jan 2026PySide6 6.10.1, Python 3.14, WaylandRelated 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.

2.3 PYSIDE-3288 — first-party fix (headline finding)

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:

  1. Which release it shipped in (6.10.0? 6.11.0?) — i.e., whether both installed versions (6.10.2, 6.11.1) have it.
  2. How it is enabled — API call, environment variable, or default-on. "Now possible" reads as opt-in.
  3. Whether "defer deletion" means routing through 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.

2.4 Python 3.14 GC timing changes

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.

3. Q2 — Supported mitigations

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.

4. Q3 — Tooling

ToolPackages / invocationNotes
gdb + Python backtrace from coregdb, python3.14-dbg (or python3-dbg); the python3.14-gdb.py auto-load script provides py-bt, py-list, py-localsWorks 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 symbolsPip 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 timegc.set_debug(gc.DEBUG_SAVEALL) + a gc.callbacks hookLog, 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 hardeningPYTHONMALLOC=debug, python -X dev -X faulthandler -X tracemallocAll cheap; stack them. -X dev also surfaces unraisable exceptions and ResourceWarnings.
Qt-side early assertionQT_FATAL_WARNINGS=1; QT_LOGGING_RULES for finer controlThe 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 diagnosticsShiboken.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 / ValgrindValgrind 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.

5. Q4 — Diagnosis at the moment of collection

5.1 Is 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.

5.2 Enumerating every Python-owned QObject

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.

5.3 Proving a fix — what N is evidence

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.

6. What was NOT found

7. Sources

  1. PyQt mailing list, "subtle bug in PyQt in combination with Python garbage collector," Aug 2011 — riverbankcomputing.com/pipermail/pyqt/2011-August/030378.html
  2. PYSIDE-1919, "Segfault when garbage collector grabs QObject with connected signal," May 2022 — bugreports.qt.io/browse/PYSIDE-1919
  3. PySide6 official release notes (PYSIDE-3288, PYSIDE-3147) — doc.qt.io/qtforpython-6/release_notes/pyside6_release_notes.html
  4. qtpygc, "Prevent PyQt/PySide crashes due to cross-thread garbage collection" — pypi.org/project/qtpygc/
  5. napari issue #1029, "python garbage collector can trigger in any thread," 2020 — github.com/napari/napari/issues/1029
  6. T. Schoenfelder, "Incompatibility of Qt with Python's garbage collector," 2024 — tschoenfelder.de/blog/pyqt_gc/
  7. Qt Forum topic 164002, "Bug with PySide 6" (shutdown-ordering segfault, PySide6 6.10.1 / Python 3.14 / Wayland), Jan 2026 — forum.qt.io/topic/164002/bug-with-pyside-6
  8. "Python 3.14 garbage collection rigamarole" (incremental GC reverted in 3.14.5), Jun 2026 — theconsensus.dev; summary at daily.dev
  9. pytest issue #14500 (Windows-only GC-time crash on Python 3.14.5, same faulthandler signature), May 2026 — github.com/pytest-dev/pytest/issues/14500