Skip to content

test: deterministically tear down example widgets - #743

Merged
tlambert03 merged 4 commits into
pyapp-kit:mainfrom
larsoner:fix-example-teardown
Aug 26, 2026
Merged

test: deterministically tear down example widgets#743
tlambert03 merged 4 commits into
pyapp-kit:mainfrom
larsoner:fix-example-teardown

Conversation

@larsoner

Copy link
Copy Markdown
Contributor

CIs seem to be failing in all PRs. Based on (painful) experience with PyVistaQt, mne-qt-browser, etc. a reasonable fix is to make sure windows are closed and torn down at the end of every test (rather than "whenever gc.collect() decides to run"). Otherwise, a still-visible widget whose C++ side is deleted mid-event-loop can receive a paintEvent during deletion, which leads to a segfault.

This should hopefully fix CIs. Changes drafted by Claude Fable 5 but reviewed / iterated / understood be me.

@larsoner

Copy link
Copy Markdown
Contributor Author

... let me know if you want me to add a "open an issue when the scheduled one fails, if one doesn't already exist" here or in another PR. I find them useful in other repos, otherwise I miss scheduled run failures (but not sure if it would help here, depends on maintainer availability etc.)

larsoner and others added 3 commits August 26, 2026 13:59
The namespace from runpy.run_path is discarded, so widgets created by
an example are garbage-collected at an arbitrary later point during
event processing.  A still-visible widget whose C++ side is deleted
mid-event-loop can receive a paintEvent during deletion, which PyQt5
escalates to a process abort:

    RuntimeError: wrapped C/C++ object of type QRangeSlider has been
    deleted

as seen intermittently on the windows-latest (3.12) pyqt5 job in
test_example[demo_widgets/range_slider.py] (e.g. the scheduled main
run on 2026-08-22, and both pyapp-kit#741 and pyapp-kit#742).

Close (hide) all top-level widgets first so nothing can paint, then
deleteLater() + sendPostedEvents(None, DeferredDelete) to destroy the
native widgets (and stop their timers) while hidden -- note that
processEvents() alone does NOT dispatch DeferredDelete events -- then
gc.collect() for anything left over.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pytest-qt 4.4.0 is incompatible with recent PySide6: qtbot.waitSignal
fails with 'SystemError: <method disconnect of SignalInstance> returned
a result with an exception set' and a TimeoutError in
test_parent_changed (reproduced locally with PySide6 6.11.2 +
pytest-qt 4.4.0; fixed by pytest-qt 4.5.0, see pytest-dev/pytest-qt#552).
This is what has been failing every pyside6 CI job (and the scheduled
runs on main) since at least mid-July.

pytest-qt 4.5 dropped PySide2, so the pyside2 dependency group keeps
the 4.4.0 pin via a separate test-qt-legacy group, declared as a uv
conflict so universal resolution still succeeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hatchling 1.32.0 (2026-08-11) bumped the default core metadata version
to 2.5, which the twine run by build-and-inspect-python-package rejects
('InvalidDistribution: 2.5 is not a valid metadata version').  Nobody
noticed because build-and-inspect needs the test job, which has been
red since mid-July (see previous commit).  Verified locally: with this
setting both the wheel and sdist emit Metadata-Version 2.4 and pass
'twine check --strict'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tlambert03
tlambert03 force-pushed the fix-example-teardown branch from ad0b40f to e0a9da5 Compare August 26, 2026 12:02
@tlambert03

Copy link
Copy Markdown
Member

Rebased this onto current main and dropped the napari commit (23cc061), which #727 superseded this morning — that job now runs on 3.13 with package-group: testing and is passing. That was also the only merge conflict, so the branch is clean now.

Confirmed your pytest-qt diagnosis independently before touching anything: the failure is _AbstractSignalBlocker._cleanup calling _silent_disconnect(self._timer.timeout, ...), which only catches (TypeError, RuntimeError). PySide6 6.11.2 raises SystemError there instead, so it escapes inside a psygnal callback → EmitLoopErrorwaitSignal never returns → TimeoutError. pytest-qt 4.5.0 removed that disconnect from the cleanup path entirely, so the bug can't trigger.

Verified locally with pyside6==6.11.2 (what CI resolves; our lock had been pinning 6.11.0, which is why this reproduced only in CI): 423 passed. PyQt5/PyQt6 also green, and the conflicts split resolves correctly — pyside2pytest-qt==4.4.0, everything else → 4.5.0.

I left the hatchling core-metadata commit in. Worth noting it hasn't actually been exercised yet: build-and-inspect-package has needs: test, so it's been skipped this whole time. We'll find out on this run.

Thanks for chasing this one down.

@larsoner

Copy link
Copy Markdown
Contributor Author

You still have 3.9 tests... want to bump to 3.10 (or if you're bold, 3.11)? Happy to do this in another maintainability PR

@tlambert03

Copy link
Copy Markdown
Member

i can bump min python elsewhere. thanks.

and as for the "open an issue when the scheduled one fails", I too use/like that elsewhere, but i fear I'm the only one listening here at the moment, and I'm feeling pretty buried recently (and likely wouldn't take action on it until i had to anyway)

@tlambert03
tlambert03 merged commit b862357 into pyapp-kit:main Aug 26, 2026
34 checks passed
@larsoner
larsoner deleted the fix-example-teardown branch August 26, 2026 12:41
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.

2 participants