From 26fbe2ca421eafd550b2696173fb40c6d8114abc Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 13:28:05 +0200 Subject: [PATCH] fix: give deferred singleShot callbacks a receiver context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the long-standing cross-test SystemError flake ("QPushButton returned NULL", CI failing test_title_label after an unrelated file): TitleLabel schedules QTimer.singleShot(0, self._apply_collapsed) at construction; when the banner is deleted before the event loop spins (what every panel test does), the pending timer fires into the dead C++ object and poisons whatever test runs next. Deterministic repro added as a regression test. The receiver-context overload singleShot(0, self, ...) auto- cancels the callback when the widget dies. Same fix for the two deferred main-window callbacks (dock split, floating-dock enlarge). Note for the record: the LOCAL segfault storm this afternoon was the dev machine's disk hitting 100% full (uv cache at 15G) — Bus errors from mmap on a full disk, unrelated to any code. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 4 ++-- src/aare/gui/widgets/title_label.py | 8 ++++++-- tests/unit/gui/test_title_label.py | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index ade38ac7..e91d5dde 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2865,7 +2865,7 @@ class MainWindow(QMainWindow): if not self._default_dock_split_done: self._default_dock_split_done = True if not self.state_manager.settings.value("main_window/state"): - QTimer.singleShot(0, self._apply_default_dock_split) + QTimer.singleShot(0, self, self._apply_default_dock_split) def _apply_default_dock_split(self) -> None: self.resizeDocks( @@ -3022,7 +3022,7 @@ class MainWindow(QMainWindow): # Pop-outs open enlarged instead of keeping the cramped docked # size. Deferred: the window is mid-reparent while the signal # fires. - QTimer.singleShot(0, lambda d=dock: self._enlarge_floating_dock(d)) + QTimer.singleShot(0, dock, lambda d=dock: self._enlarge_floating_dock(d)) def _enlarge_floating_dock(self, dock: QDockWidget) -> None: if not dock.isFloating(): diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index 6c5d5c28..0abc91fa 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -89,8 +89,12 @@ class TitleLabel(QLabel): if settings.value(self._settings_key, default_collapsed, type=bool): self._collapsed = True # Deferred: the panel adds its other widgets after constructing - # the TitleLabel, so siblings don't exist yet. - QTimer.singleShot(0, self._apply_collapsed) + # the TitleLabel, so siblings don't exist yet. `self` as the + # receiver context, or the pending timer outlives a deleted + # banner and fires into the dead C++ object — the source of the + # long-standing "QPushButton returned NULL" SystemError flake + # poisoning whatever test runs next. + QTimer.singleShot(0, self, self._apply_collapsed) def paintEvent(self, event): # QSS has no text-shadow, so paint by hand: the QSS background box diff --git a/tests/unit/gui/test_title_label.py b/tests/unit/gui/test_title_label.py index 168dc31d..daf48ecf 100644 --- a/tests/unit/gui/test_title_label.py +++ b/tests/unit/gui/test_title_label.py @@ -73,3 +73,19 @@ def test_not_collapsible_by_default(qtbot): title = TitleLabel("Plain", panel) grid.addWidget(title, 0, 0) assert not hasattr(title, "toggle_button") + + +def test_pending_collapse_timer_dies_with_the_banner(): + """The deferred _apply_collapsed must not fire into a deleted banner — + that was the historic "QPushButton returned NULL" / SystemError flake + poisoning whichever test ran next (singleShot without receiver context + outlives the widget).""" + from PySide6.QtWidgets import QApplication + + _remove_key() + host = QWidget() + TitleLabel("DoomedBanner", host, collapsible=True, default_collapsed=True) + # Python-owned host: del cascades the C++ delete to the banner while + # its singleShot(0) is still pending. + del host + QApplication.processEvents() # must not raise into the event loop