[PyQt6 6.11.0] QSplitterHandle wrapper invalidation stops after recreating QApplication

조주연 chojuyeoun at gmail.com
Fri Sep 11 05:22:34 BST 2026


Hello,

I would like to report a deterministic QObject wrapper-invalidation
difference after a QApplication is destroyed and a new QApplication is
created later in the same Python process. Only one QApplication exists at
any time, and both are created on the main thread.

Before treating this as a bug, I would also like to clarify whether
sequential QApplication lifetimes are supported by PyQt6.

Summary
-------

The attached small, complete PyQt-only script creates QSplitter objects,
retains the Python wrappers returned by QSplitter.handle(1), deletes each
owning splitter, and then observes the wrappers with PyQt6.sip.isdeleted().

It compares two modes, each run in a fresh Python process:

1. single: both epochs use the same QApplication.
2. recreate: the first QApplication is fully destroyed before a second
   QApplication is constructed.

Under a single QApplication, handle wrappers are invalidated in both
epochs. After recreating QApplication, all handle destroyed slots are still
called under the second application, and all owning splitter wrappers are
marked deleted, but none of the retained handle wrappers are marked deleted.

Environment
-----------

Primary current-version reproduction:

- Windows 11 10.0.26200, 64-bit
- CPython 3.12.14, 64-bit
- PyQt6 6.11.0
- PyQt6-Qt6 6.11.2
- Qt compile-time version 6.11.0
- Qt runtime version 6.11.2
- PyQt6-sip 13.12.0
- Reproduced with QT_QPA_PLATFORM=offscreen and windows

The same result was also observed with:

- PyQt6 6.7.1
- Qt compile-time version 6.7.1
- Qt runtime version 6.7.3
- PyQt6-sip 13.8.0

How to run
----------

Run each command in a fresh process:

    py -3.12 minimal_repro.py single
    py -3.12 minimal_repro.py recreate

The script defaults to the offscreen QPA plugin. To use the Windows plugin:

    set QT_QPA_PLATFORM=windows
    py -3.12 minimal_repro.py single
    py -3.12 minimal_repro.py recreate

Each command exits with status 0, writes JSON to stdout, and writes nothing
to stderr in my environment.

Expected result
---------------

If constructing a new QApplication after completely destroying the previous
one is supported, QObject wrapper lifetime tracking under the second
application should behave like it does under the first application.

After the owning QSplitter is destroyed and the handle's destroyed signal is
emitted, the retained QSplitterHandle wrapper should report
sip.isdeleted(handle) == True.

Actual result on PyQt6 6.11.0
----------------------------

    mode       epoch   splitter deleted   handle deleted   destroyed slot
seen
    single       0          16/16              16/16              16/16
    single       1          16/16              16/16              16/16
    recreate     0          16/16              16/16              16/16
    recreate     1          16/16               0/16              16/16

At the recreate boundary, before constructing the second application:

- the weak reference to the first QApplication wrapper is dead; and
- QApplication.instance() is None.

Immediately after construction, QApplication.instance() is the second
application.

For PyQt6 6.11.0 with the offscreen plugin, I ran single mode in 12 fresh
processes and recreate mode in 12 fresh processes. All 24 runs produced the
same respective result and empty stderr. One additional fresh-process run of
each mode with the Windows QPA plugin produced the same result.

An earlier PyQt6 6.7.1 matrix also compared no destroyed callback, a plain
Python callback, and the class-defined zero-argument slot used here. Each
single/recreate cell was run in 12 fresh processes. All recreated cells had
192/192 handles marked deleted under the first application and 0/192 under
the second, irrespective of callback type.

Safety and observer design
--------------------------

The reproducer intentionally does not force a crash. After delivering
DeferredDelete, it calls no Qt method on the splitter or handle. It reads
only
sip.isdeleted(), plus a Python Boolean changed by the destroyed slot.

The destroyed receiver is a strongly retained QObject subclass with a
class-defined, zero-argument @pyqtSlot(). It receives no QObject argument
and
does not access sender() or capture the handle.

Source-level observation and hypothesis
---------------------------------------

This is a hypothesis based on source inspection, not a confirmed diagnosis
of the installed wheel's compiled control flow.

I downloaded the PyQt6 6.11.0 sdist from PyPI. Its SHA-256 is:

    45dd60aa69976de1918b5ced6b4e7b6a25abd2a919ecef5fd5826ecc76718889

In that source tree:

- sip/QtWidgets/qapplication.sip calls pyqt6_cleanup_qobjects() from the
  QApplication destructor.
- sip/QtCore/qcoreapplication.sip does the same from the QCoreApplication
  destructor.
- qpy/QtCore/qpycore_public_api.cpp, around lines 75-80, sets
  PyQtMonitor::enabled = false during that cleanup.
- qpy/QtCore/qpycore_event_handlers.cpp initializes enabled to true and only
  installs monitoring for a newly wrapped C++ QObject while it is enabled.
- I did not find a path in qpy/ or sip/ that sets the flag back to true when
  a later QApplication is constructed.

This appears consistent with application destruction disabling monitoring
for C++-created QObject wrappers during the second application lifetime.
However, I have not verified the wheel at native-symbol or machine-code
level,
so I am presenting this only as a source-level hypothesis for confirmation.

Impact and limitation
---------------------

I found this while investigating a native Windows access violation in a
larger GUI test sequence. The attached reproducer demonstrates only the
deterministic wrapper-invalidation gap and deliberately stops short of using
a potentially stale wrapper.

It does not identify the wrapper involved in that larger access violation or
the faulting native instruction. The access violation is therefore context,
not proof of the source-level hypothesis.

Questions
---------

1. Does PyQt6 support completely destroying a QApplication and constructing
   another one later in the same process?
2. If so, should PyQtMonitor be re-enabled or reset for the new application
   lifetime?
3. If sequential application lifetimes are intentionally unsupported, is one
   QApplication per process the required contract?

Workaround
----------

Keeping one QApplication alive for the entire process avoids the issue in my
tests. Tests that require an independent application lifetime can be run in
a
separate subprocess.

Attachments
-----------

- minimal_repro.py
- single-pyqt6-6.11.0-final.json
- recreate-pyqt6-6.11.0-final.json
- single-pyqt6-6.11.0-windows-qpa-final.json
- recreate-pyqt6-6.11.0-windows-qpa-final.json
- README.txt
- SHA256SUMS.txt

Thank you.
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://www.riverbankcomputing.com/pipermail/pyqt/attachments/20260911/2ac22a17/attachment-0001.htm>
-------------- next part --------------
A non-text attachment was scrubbed...
Name: pyqt6-qapplication-recreate-report.zip
Type: application/x-zip-compressed
Size: 7148 bytes
Desc: not available
URL: <https://www.riverbankcomputing.com/pipermail/pyqt/attachments/20260911/2ac22a17/attachment-0001.bin>


More information about the PyQt mailing list