fix: avoid packaged Qt callback retention

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-30 19:13:43 +08:00
parent 120583f6d6
commit 3e849d9301
13 changed files with 200 additions and 61 deletions
+4 -2
View File
@@ -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)
+12 -4
View File
@@ -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)
+25 -10
View File
@@ -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()
+12 -6
View File
@@ -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):
+2 -1
View File
@@ -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."""
+47 -20
View File
@@ -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(
+14 -5
View File
@@ -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):
+2 -1
View File
@@ -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')
+5 -1
View File
@@ -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()
+17 -5
View File
@@ -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):
+10 -4
View File
@@ -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."""
+7 -2
View File
@@ -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)
+43
View File
@@ -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()