mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
fix: avoid packaged Qt callback retention
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user