diff --git a/Furious/Backends/Xray/AssetWindow.py b/Furious/Backends/Xray/AssetWindow.py index 1095dc6..66ad816 100644 --- a/Furious/Backends/Xray/AssetWindow.py +++ b/Furious/Backends/Xray/AssetWindow.py @@ -88,13 +88,15 @@ class XrayAssetWindow(AppQMainWindow): _('Delete'), icon=bootstrapIcon('trash.svg'), ) - self.deleteButton.clicked.connect(self.deleteSelectedItem) + + connectWeakly(self.deleteButton.clicked, self, 'deleteSelectedItem') self.closeWindowButton = AppQPushButton( _('Close Window'), icon=bootstrapIcon('window-x.svg'), ) - self.closeWindowButton.clicked.connect(self.close) + + connectWeakly(self.closeWindowButton.clicked, self, 'close') actionLayout = QHBoxLayout() actionLayout.setContentsMargins(0, 0, 0, 0) diff --git a/Furious/Backends/Xray/RoutingWindow.py b/Furious/Backends/Xray/RoutingWindow.py index e6a655d..acf89a7 100644 --- a/Furious/Backends/Xray/RoutingWindow.py +++ b/Furious/Backends/Xray/RoutingWindow.py @@ -1227,10 +1227,18 @@ class XrayRoutingWindow(AppQMainWindow): ), ) - self.addButton.clicked.connect(self.tableView.appendNewItem) - self.previewButton.clicked.connect(self.tableView.previewSelectedItem) - self.renameButton.clicked.connect(self.tableView.renameSelectedItem) - self.deleteButton.clicked.connect(self.tableView.deleteSelectedItem) + for button, methodName in ( + (self.addButton, 'appendNewItem'), + (self.previewButton, 'previewSelectedItem'), + (self.renameButton, 'renameSelectedItem'), + (self.deleteButton, 'deleteSelectedItem'), + ): + connectWeakly( + button.clicked, + self.tableView, + methodName, + sender=button, + ) actionLayout = QHBoxLayout() actionLayout.setContentsMargins(0, 0, 0, 0) diff --git a/Furious/Core/CoreProcessWorker.py b/Furious/Core/CoreProcessWorker.py index 58dd114..f242376 100644 --- a/Furious/Core/CoreProcessWorker.py +++ b/Furious/Core/CoreProcessWorker.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * +from Furious.Qt.Signals import connectWeakly from PySide6 import QtCore @@ -150,7 +151,11 @@ class MsgQueue(multiprocessing.queues.Queue): ) self.timer = QtCore.QTimer() - self.timer.timeout.connect(self.processMsg) + self._timerConnection = connectWeakly( + self.timer.timeout, + self, + 'processMsg', + ) self.timeout = self.ACTIVE_DRAIN_INTERVAL self.callback = msgCallback @@ -183,10 +188,13 @@ class MsgQueue(multiprocessing.queues.Queue): """Release Qt and multiprocessing resources after final process use.""" self.stopTimer() - try: - self.timer.timeout.disconnect(self.processMsg) - except (RuntimeError, TypeError): - pass + if self._timerConnection is not None: + try: + QtCore.QObject.disconnect(self._timerConnection) + except (RuntimeError, TypeError): + pass + + self._timerConnection = None self.timer.deleteLater() self.callback = None @@ -271,7 +279,11 @@ class CoreProcessMonitor(CoreRuntime, ABC): self._lastExitCode = None self._daemon = QtCore.QTimer() - self._daemon.timeout.connect(self.queryIsAlive) + self._daemonConnection = connectWeakly( + self._daemon.timeout, + self, + 'queryIsAlive', + ) @property def process(self) -> Union[multiprocessing.Process, None]: @@ -346,10 +358,13 @@ class CoreProcessMonitor(CoreRuntime, ABC): """Release the monitor timer after this runtime leaves its owner pool.""" self.daemon.stop() - try: - self.daemon.timeout.disconnect(self.queryIsAlive) - except (RuntimeError, TypeError): - pass + if self._daemonConnection is not None: + try: + QtCore.QObject.disconnect(self._daemonConnection) + except (RuntimeError, TypeError): + pass + + self._daemonConnection = None self.daemon.deleteLater() self.closeProcess() diff --git a/Furious/Qt/QtGui.py b/Furious/Qt/QtGui.py index 0da5e46..90dea75 100644 --- a/Furious/Qt/QtGui.py +++ b/Furious/Qt/QtGui.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Qt.DynamicTranslate import gettext as _ +from Furious.Qt.Signals import connectWeakly from PySide6 import QtCore from PySide6.QtGui import * @@ -177,15 +178,20 @@ class AppQAction(Mixins.QTranslatable, Mixins.ThemeAware, QAction): if shortcut is not None: self.setShortcut(shortcut) - # Connect to a real QObject method rather than a nested closure that - # captures this action. PySide stores Python callables connected to a - # signal outside the normal Python object graph; a closure here leaves - # an otherwise unowned QAction alive indefinitely. - self.triggered.connect(self._handleTriggered) + # Keep the compiled callback outside Nuitka's process-global bound-method + # protection. The weak dispatcher resolves this action only while its + # Python wrapper and native QAction are still alive. + connectWeakly(self.triggered, self, '_handleTriggered') @QtCore.Slot(bool) - def _handleTriggered(self, paramChecked): + def _handleTriggered(self, paramChecked=None): """Dispatch activation without creating a signal/self reference cycle.""" + # QAction.triggered(bool) exposes its argument as optional to Python, so + # PySide may select the zero-argument form for a variadic dispatcher. + # Recover the authoritative state from the action in that case. + if paramChecked is None: + paramChecked = self.isChecked() + logger.info(f'action is \'{self.textEnglish}\'. Checked is {paramChecked}') if callable(self.callback): diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 22981d7..2b8fcf0 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -2075,7 +2075,8 @@ class AppQMenuPushButton(AppQPushButton): self._popupMenu = None self.setPopupMenu(popupMenu) - self.clicked.connect(self.showPopupMenu) + + connectWeakly(self.clicked, self, 'showPopupMenu') def popupMenu(self): """Return the menu presented by this button.""" diff --git a/Furious/Qt/Signals.py b/Furious/Qt/Signals.py index e1e648d..ab45f8a 100644 --- a/Furious/Qt/Signals.py +++ b/Furious/Qt/Signals.py @@ -43,26 +43,39 @@ def _ownsQObject(owner, object_) -> bool: return False -def _weakMethodInvoker( - receiver: Any, - methodName: str, - *, - sender=None, - forwardSender: bool = False, -): - """Return a plain callable that weakly dispatches to one named method.""" - if not isinstance(methodName, str) or not methodName: - raise ValueError('method name must be a non-empty string') +class _WeakMethodInvoker: + """Weakly resolve one named method without a closure or bound callback.""" - if forwardSender and sender is None: - raise ValueError('forwarding requires an explicit sender') + __slots__ = ( + '_receiverReference', + '_methodName', + '_senderReference', + '_forwardSender', + ) - receiverReference = weakref.ref(receiver) - senderReference = weakref.ref(sender) if sender is not None else None + def __init__( + self, + receiver: Any, + methodName: str, + *, + sender=None, + forwardSender: bool = False, + ): + """Retain only weak QObject owners and immutable dispatch metadata.""" + if not isinstance(methodName, str) or not methodName: + raise ValueError('method name must be a non-empty string') - def invoke(*args, **kwargs): + if forwardSender and sender is None: + raise ValueError('forwarding requires an explicit sender') + + self._receiverReference = weakref.ref(receiver) + self._methodName = methodName + self._senderReference = weakref.ref(sender) if sender is not None else None + self._forwardSender = forwardSender + + def __call__(self, *args, **kwargs): """Invoke the named method while its Python and Qt owners remain valid.""" - currentReceiver = receiverReference() + currentReceiver = self._receiverReference() if currentReceiver is None: return None @@ -70,12 +83,12 @@ def _weakMethodInvoker( if isinstance(currentReceiver, QtCore.QObject) and not isValid(currentReceiver): return None - method = getattr(currentReceiver, methodName) + method = getattr(currentReceiver, self._methodName) - if not forwardSender: + if not self._forwardSender: return method(*args, **kwargs) - currentSender = senderReference() + currentSender = self._senderReference() if currentSender is None: return None @@ -85,7 +98,21 @@ def _weakMethodInvoker( return method(currentSender, *args, **kwargs) - return invoke + +def _weakMethodInvoker( + receiver: Any, + methodName: str, + *, + sender=None, + forwardSender: bool = False, +): + """Return a plain callable object that weakly dispatches one named method.""" + return _WeakMethodInvoker( + receiver, + methodName, + sender=sender, + forwardSender=forwardSender, + ) def connectWeakly( diff --git a/Furious/Qt/TextEditor.py b/Furious/Qt/TextEditor.py index fc40084..4fb3977 100644 --- a/Furious/Qt/TextEditor.py +++ b/Furious/Qt/TextEditor.py @@ -20,6 +20,7 @@ from __future__ import annotations from Furious.Frozenlib import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt.TextEditorTheme import * from PySide6 import QtCore @@ -306,11 +307,19 @@ class DraculaTextEditor(Mixins.ThemeAware, AppQPlainTextEdit): ) ) - # QObject-bound slots are important here. Nested closures connected to - # the editor's own signals retain the complete editor widget tree in - # PySide even after its window has been destroyed. - self.modificationChanged.connect(self._handleModificationChanged) - self.cursorPositionChanged.connect(self._handleCursorPositionChanged) + # Nuitka protects compiled bound methods passed directly to connect(). + # Resolve these callbacks weakly so closed transient editors can release + # their Python wrappers as well as their native widget trees. + connectWeakly( + self.modificationChanged, + self, + '_handleModificationChanged', + ) + connectWeakly( + self.cursorPositionChanged, + self, + '_handleCursorPositionChanged', + ) @QtCore.Slot(bool) def _handleModificationChanged(self, changed): diff --git a/Furious/Widget/ConnectionProgressWidget.py b/Furious/Widget/ConnectionProgressWidget.py index d572c2f..7db9c3b 100644 --- a/Furious/Widget/ConnectionProgressWidget.py +++ b/Furious/Widget/ConnectionProgressWidget.py @@ -42,7 +42,8 @@ class ConnectionProgressBar(Mixins.ConnectionAware, QProgressBar): # to a normal method avoids a parentless timer/closure cycle surviving # after the widget's Qt lifetime ends. self.timer = QtCore.QTimer(self) - self.timer.timeout.connect(self._advance) + + connectWeakly(self.timer.timeout, self, '_advance') self._setConnectionState('disconnected') diff --git a/Furious/Widget/WaitingSpinner.py b/Furious/Widget/WaitingSpinner.py index 40b649f..39893dc 100644 --- a/Furious/Widget/WaitingSpinner.py +++ b/Furious/Widget/WaitingSpinner.py @@ -45,6 +45,8 @@ SOFTWARE. from __future__ import annotations +from Furious.Qt.Signals import connectWeakly + from PySide6.QtCore import QRect, Qt, QTimer from PySide6.QtGui import QColor, QPainter, QPaintEvent from PySide6.QtWidgets import QWidget @@ -92,7 +94,9 @@ class WaitingSpinner(QWidget): self._is_spinning: bool = False self._timer: QTimer = QTimer(self) - self._timer.timeout.connect(self._rotate) + + connectWeakly(self._timer.timeout, self, '_rotate') + self._update_size() self._update_timer() self.hide() diff --git a/Furious/Window/NetworkTestDialog.py b/Furious/Window/NetworkTestDialog.py index cae14c2..d8717b9 100644 --- a/Furious/Window/NetworkTestDialog.py +++ b/Furious/Window/NetworkTestDialog.py @@ -73,13 +73,25 @@ class NetworkTestDialog(AppQDialog): self.dialogBtns.addButton( _('Cancel'), AppQDialogButtonBox.ButtonRole.RejectRole ) - self.dialogBtns.accepted.connect(self.accept) - self.dialogBtns.rejected.connect(self.reject) + + connectWeakly(self.dialogBtns.accepted, self, 'accept') + connectWeakly(self.dialogBtns.rejected, self, 'reject') self.speedTestURLResetBtn = AppQPushButton(_('Reset')) - self.speedTestURLResetBtn.clicked.connect(self._resetSpeedTestURL) + + connectWeakly( + self.speedTestURLResetBtn.clicked, + self, + '_resetSpeedTestURL', + ) + self.connectivityResetBtn = AppQPushButton(_('Reset')) - self.connectivityResetBtn.clicked.connect(self._resetConnectivityURL) + + connectWeakly( + self.connectivityResetBtn.clicked, + self, + '_resetConnectivityURL', + ) self.speedTestURLHboxLayout = QHBoxLayout() self.speedTestURLHboxLayout.addWidget(self.speedTestURLEdit) @@ -99,7 +111,7 @@ class NetworkTestDialog(AppQDialog): self.setLayout(layout) - self.finished.connect(self.handleResultCode) + connectWeakly(self.finished, self, 'handleResultCode') @QtCore.Slot() def _resetSpeedTestURL(self): diff --git a/Furious/Window/ProxyBypassDialog.py b/Furious/Window/ProxyBypassDialog.py index 9529f32..a6a9f5d 100644 --- a/Furious/Window/ProxyBypassDialog.py +++ b/Furious/Window/ProxyBypassDialog.py @@ -62,11 +62,17 @@ class ProxyBypassDialog(AppQDialog): self.dialogBtns.addButton( _('Cancel'), AppQDialogButtonBox.ButtonRole.RejectRole ) - self.dialogBtns.accepted.connect(self.accept) - self.dialogBtns.rejected.connect(self.reject) + + connectWeakly(self.dialogBtns.accepted, self, 'accept') + connectWeakly(self.dialogBtns.rejected, self, 'reject') self.resetBtn = AppQPushButton(_('Reset')) - self.resetBtn.clicked.connect(self.handleResetButtonClicked) + + connectWeakly( + self.resetBtn.clicked, + self, + 'handleResetButtonClicked', + ) self.hboxLayout = QHBoxLayout() self.hboxLayout.addWidget(self.resetBtn) @@ -80,7 +86,7 @@ class ProxyBypassDialog(AppQDialog): self.setLayout(layout) - self.finished.connect(self.handleResultCode) + connectWeakly(self.finished, self, 'handleResultCode') def handleResultCode(self, code): """Handle result code.""" diff --git a/Furious/Window/QRCodeWindow.py b/Furious/Window/QRCodeWindow.py index 023373f..6a1c1e9 100644 --- a/Furious/Window/QRCodeWindow.py +++ b/Furious/Window/QRCodeWindow.py @@ -22,7 +22,7 @@ from __future__ import annotations from Furious.Frozenlib import APPLICATION_NAME from Furious.Plugins import exportConfiguration from Furious.Repository import Storage -from Furious.Qt import AppQMainWindow, AppQTabWidget +from Furious.Qt import AppQMainWindow, AppQTabWidget, connectWeakly from Furious.Qt import gettext as _ from PySide6 import QtCore @@ -179,7 +179,12 @@ class QRCodeWindow(AppQMainWindow): self.tabWidget = AppQTabWidget(parent=self, translatable=False) self.tabWidget.setTabsClosable(True) self.tabWidget.setElideMode(QtCore.Qt.TextElideMode.ElideRight) - self.tabWidget.tabCloseRequested.connect(self.handleTabCloseRequested) + + connectWeakly( + self.tabWidget.tabCloseRequested, + self, + 'handleTabCloseRequested', + ) self.setCentralWidget(self.tabWidget) diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py index db47eae..4eb7422 100644 --- a/tests/test_qt_lifetime.py +++ b/tests/test_qt_lifetime.py @@ -197,6 +197,49 @@ class QtLifetimeTest(unittest.TestCase): for pool, baseline in poolBaselines.items(): self.assertEqual(len(pool.ObjectsPool), baseline) + def testCompiledBoundMethodProtectionDoesNotRetainTransientActions(self): + """Keep Nuitka-like protected callbacks without retaining deleted actions.""" + originalConnect = QtCore.SignalInstance.connect + protectedCallbacks = [] + triggered = [] + + def protectingConnect(signal, callback, *args, **kwargs): + """Emulate the packaged runtime's global bound-method protection.""" + if getattr(callback, '__self__', None) is not None: + protectedCallbacks.append(callback) + + return originalConnect(signal, callback, *args, **kwargs) + + references = [] + + with mock.patch.object( + QtCore.SignalInstance, + 'connect', + protectingConnect, + ): + for index in range(100): + action = AppQAction( + 'Fixture action', + callback=lambda value=index: triggered.append(value), + ) + references.append(weakref.ref(action)) + + action.trigger() + action.deleteLater() + + del action + + collectAtBoundary() + + self.assertEqual(triggered, list(range(100))) + self.assertTrue(all(reference() is None for reference in references)) + self.assertFalse( + any( + isinstance(getattr(callback, '__self__', None), AppQAction) + for callback in protectedCallbacks + ) + ) + def testAsyncDialogRegistryRetainsUntilNativeDestruction(self): """Keep a delete-on-close wrapper alive through deferred destruction.""" dialog = ProbeTransientDialog()