diff --git a/Furious/Qt/AGENTS.md b/Furious/Qt/AGENTS.md index 66f3f09..7bb8ed6 100644 --- a/Furious/Qt/AGENTS.md +++ b/Furious/Qt/AGENTS.md @@ -53,7 +53,9 @@ primitives; pages and services consume them without creating parallel registries arbitrary worker calls to the GUI thread; choose an explicit queued owner-thread delivery boundary. A surviving Python wrapper can already be natively invalid, so callback freshness and `shiboken6.isValid()` address different failure modes. Neither replaces the strong owner required while asynchronous UI remains active. -- `AppQAction.callback` is strong by design, so the action owner cannot outlive the captured receiver. +- `AppQAction.callback` is strong by design, so the action owner cannot outlive the captured receiver. An action + also owns a submenu supplied without a QWidget parent and schedules its native deletion when the action dies; + `QAction.setMenu()` alone does not establish parent ownership. Explicitly parented menus retain their chosen owner. - Every `QNetworkReply` has one manager/context owner, one freshness rule, and one terminal deletion path. Request context must also be released when native destruction skips `finished`, including manager-first teardown with retained Python wrappers. Use the shared network-manager tracking boundary; cleanup must not capture a reply diff --git a/Furious/Qt/QtGui.py b/Furious/Qt/QtGui.py index 90dea75..9a6d926 100644 --- a/Furious/Qt/QtGui.py +++ b/Furious/Qt/QtGui.py @@ -147,6 +147,12 @@ class AppQAction(Mixins.QTranslatable, Mixins.ThemeAware, QAction): # Create reference self._menu = menu + if menu.parent() is None: + # setMenu() associates a submenu without giving it a Qt parent. + # This action owns otherwise unparented menus; explicit widget + # parents retain their own lifetime. deleteLater is a native slot. + self.destroyed.connect(menu.deleteLater) + # Some old version PySide6 does not have setMenu method # for QAction. Protect it. Currently only used in TrayIcon if hasattr(self, 'setMenu') and useSetMenu: diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py index a17b9cb..24017e2 100644 --- a/tests/test_qt_lifetime.py +++ b/tests/test_qt_lifetime.py @@ -220,6 +220,35 @@ class QtLifetimeTest(unittest.TestCase): self.assertAllDestroyed(references, destroyed, 60) + def testActionOwnedMenusDieWithActionAndPreserveExplicitOwners(self): + """QAction's menu association must not strand an unparented native menu.""" + references = [] + destroyed = [] + + for explicitOwner in (False, True): + for _ in range(30): + owner = QWidget() + menu = AppQMenu(parent=owner if explicitOwner else None) + action = AppQAction('Menu fixture', menu=menu, parent=owner) + references.append(weakref.ref(menu)) + menu.destroyed.connect(lambda *_args: destroyed.append(True)) + + action.deleteLater() + processQtEvents() + + self.assertFalse(isValid(action)) + try: + self.assertEqual(isValid(menu), explicitOwner) + finally: + if isValid(menu): + menu.deleteLater() + owner.deleteLater() + processQtEvents() + + del action, menu, owner + + self.assertAllDestroyed(references, destroyed, 60) + def testReopenedDialogSurvivesPreviousPresentationCleanup(self): """A queued finish must not release the next asynchronous presentation.""" destroyed = []