diff --git a/Furious/Backends/ExternalCore/Editor.py b/Furious/Backends/ExternalCore/Editor.py index 88cd4aa1..a9930328 100644 --- a/Furious/Backends/ExternalCore/Editor.py +++ b/Furious/Backends/ExternalCore/Editor.py @@ -36,6 +36,7 @@ from Furious.Qt import ( GuiEditorWidgetQGroupBox, ) from Furious.Qt import gettext as _ +from Furious.Qt.Signals import connectWeakly from PySide6 import QtCore from PySide6.QtWidgets import ( @@ -81,7 +82,7 @@ class ExternalCorePathInput(EditorWidgetBinding): self._input = AppQLineEdit() self._input.setPlaceholderText(_(placeholder) if placeholder else '') self._browse = AppQPushButton(_('Browse...')) - self._browse.clicked.connect(self.browse) + connectWeakly(self._browse.clicked, self, 'browse') container = QWidget() @@ -297,9 +298,9 @@ class ExternalCoreApplicationTun2socksInput(GuiEditorItemTextSwitch): """Initialize the application tun2socks opt-in control.""" super().__init__(title=_('Use Application Tun2socks'), translatable=True) - def connectToggled(self, callback): - """Connect a same-lifetime field-state callback to this control.""" - self._input.toggled.connect(callback) + def connectToggled(self, receiver, methodName: str): + """Connect a field-state callback without retaining its binding.""" + connectWeakly(self._input.toggled, receiver, methodName) def inputToFactory(self, config: CoreConfiguration) -> bool: """Persist the explicit application tun2socks preference.""" @@ -434,7 +435,8 @@ class ExternalCoreEditor(GuiEditorWidgetQDialog): self._applicationTun2socksInput = ExternalCoreApplicationTun2socksInput() self._tunRemoteAddressInput = ExternalCoreTunRemoteAddressInput() self._applicationTun2socksInput.connectToggled( - self._tunRemoteAddressInput.setEnabled + self._tunRemoteAddressInput, + 'setEnabled', ) self._tunRemoteAddressInput.setEnabled(False) diff --git a/Furious/Backends/Hysteria1/Editor.py b/Furious/Backends/Hysteria1/Editor.py index c0f53af9..727ca86c 100644 --- a/Furious/Backends/Hysteria1/Editor.py +++ b/Furious/Backends/Hysteria1/Editor.py @@ -23,6 +23,7 @@ from Furious.Frozenlib import * from Furious.Interface import * from Furious.Models import CoreConfiguration, Protocol from Furious.Qt import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt import gettext as _ from PySide6 import QtCore @@ -252,7 +253,7 @@ class GuiHy1ProjectWebsiteURL(AppQLabel): super().__init__(*args, **kwargs) self.setWebsiteURL() - self.linkActivated.connect(self.handleLinkActivated) + connectWeakly(self.linkActivated, self, 'handleLinkActivated') def setWebsiteURL(self): """Set website URL.""" diff --git a/Furious/Backends/Hysteria2/Editor.py b/Furious/Backends/Hysteria2/Editor.py index fa3888ab..9031545c 100644 --- a/Furious/Backends/Hysteria2/Editor.py +++ b/Furious/Backends/Hysteria2/Editor.py @@ -24,6 +24,7 @@ from Furious.Interface import * from Furious.Models import CoreConfiguration, Protocol from Furious.Backends.Configuration import ConfigHysteria2 from Furious.Qt import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt import gettext as _ from PySide6 import QtCore @@ -599,12 +600,12 @@ class GuiHy2PageObfsXXX(GuiEditorWidgetQWidget): return '' - def connectActivated(self, func): + def connectActivated(self, receiver, methodName: str): """Connect activated.""" obfsType = self._containers[0] if isinstance(obfsType, GuiEditorItemTextComboBox): - obfsType.connectActivated(func) + obfsType.connectActivated(receiver, methodName) class GuiHy2PageObfsEmpty(GuiHy2PageObfsXXX): @@ -639,8 +640,10 @@ class GuiHy2PageObfsGecko(GuiHy2PageObfsXXX): """Initialize the GuiHy2PageObfsGecko.""" super().__init__(*args, **kwargs) - self.minPacketSizeItem._input.valueChanged.connect( - self.handleMinPacketSizeChanged + connectWeakly( + self.minPacketSizeItem._input.valueChanged, + self, + 'handleMinPacketSizeChanged', ) self.handleMinPacketSizeChanged(self.minPacketSizeItem.value()) @@ -704,10 +707,10 @@ class GuiHy2ObfsPageStackedWidget(QStackedWidget): """Return the page value.""" return self._pages[index] - def connectActivated(self, func): + def connectActivated(self, receiver, methodName: str): """Connect activated.""" for page in self._pages: - page.connectActivated(func) + page.connectActivated(receiver, methodName) class GuiHy2GroupBoxBasic(GuiEditorWidgetQGroupBox): @@ -811,7 +814,7 @@ class GuiHy2ItemObfs(EditorBinding): QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum, ) - self._widget.connectActivated(self.handleActivated) + self._widget.connectActivated(self, 'handleActivated') def widgets(self): """Return the stacked obfuscation editor as one full-width form row.""" @@ -1009,7 +1012,7 @@ class GuiHy2ProjectWebsiteURL(AppQLabel): super().__init__(*args, **kwargs) self.setWebsiteURL() - self.linkActivated.connect(self.handleLinkActivated) + connectWeakly(self.linkActivated, self, 'handleLinkActivated') def setWebsiteURL(self): """Set website URL.""" diff --git a/Furious/Backends/Hysteria2/TunSettingsDialog.py b/Furious/Backends/Hysteria2/TunSettingsDialog.py index 53dd249d..c3dd3c70 100644 --- a/Furious/Backends/Hysteria2/TunSettingsDialog.py +++ b/Furious/Backends/Hysteria2/TunSettingsDialog.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Qt import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt import gettext as _ from PySide6 import QtCore @@ -242,7 +243,7 @@ class Hysteria2TUNDocumentationURL(AppQLabel): super().__init__(*args, **kwargs) self.setWebsiteURL() - self.linkActivated.connect(self.handleLinkActivated) + connectWeakly(self.linkActivated, self, 'handleLinkActivated') def setWebsiteURL(self): """Set the translated documentation hyperlink text.""" @@ -279,7 +280,7 @@ class Hysteria2TunSettingsDialog(GuiEditorWidgetQDialog): self.factoryToInput(self._settings) - self.accepted.connect(self.handleAccepted) + connectWeakly(self.accepted, self, 'handleAccepted') self.layout().takeRow(self.layout().rowCount() - 1) diff --git a/Furious/Backends/Xray/RoutingWindow.py b/Furious/Backends/Xray/RoutingWindow.py index c8a6c412..06607073 100644 --- a/Furious/Backends/Xray/RoutingWindow.py +++ b/Furious/Backends/Xray/RoutingWindow.py @@ -23,6 +23,7 @@ from Furious.Frozenlib import * from Furious.Models import * from Furious.Repository import * from Furious.Qt import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt import gettext as _ from PySide6 import QtCore @@ -176,8 +177,8 @@ class RoutingTextEditDialog(AppQTransientDialog): _('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') layout = QVBoxLayout() layout.addWidget(self.textEdit) @@ -416,8 +417,8 @@ class RoutingRuleEditDialog(AppQTransientDialog): _('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') generalLayout = QFormLayout() generalLayout.addRow( @@ -591,8 +592,8 @@ class RoutingRemarkEditDialog(AppQTransientDialog): _('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') layout = QFormLayout() layout.addRow(AppQLabel(_('Remark')), self.remarkEdit) @@ -633,8 +634,8 @@ class RoutingProfileEditDialog(AppQTransientDialog): _('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') layout = QFormLayout() layout.addRow(AppQLabel(_('Remark')), self.remarkEdit) @@ -674,7 +675,7 @@ class RoutingRulesListView(AppQListView): self.setSelectionBehavior(AppQListView.SelectionBehavior.SelectRows) self.setSelectionMode(AppQListView.SelectionMode.ExtendedSelection) self.setEditTriggers(AppQListView.EditTrigger.NoEditTriggers) - self.doubleClicked.connect(self._requestEdit) + connectWeakly(self.doubleClicked, self, '_requestEdit') self.flushAll() @@ -752,25 +753,25 @@ class RoutingRulesDialog(AppQTransientDialog): self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) self.listView = RoutingRulesListView(self.routing, parent=self) - self.listView.editRequested.connect(self.editRule) + connectWeakly(self.listView.editRequested, self, 'editRule') self.addButton = AppQPushButton( _('Add'), icon=bootstrapIcon('plus-lg.svg'), ) - self.addButton.clicked.connect(self.addRule) + connectWeakly(self.addButton.clicked, self, 'addRule') self.deleteButton = AppQPushButton( _('Delete'), icon=bootstrapIcon('trash.svg'), ) - self.deleteButton.clicked.connect(self.deleteRule) + connectWeakly(self.deleteButton.clicked, self, 'deleteRule') self.closeWindowButton = AppQPushButton( _('Close Window'), icon=bootstrapIcon('window-x.svg'), ) - self.closeWindowButton.clicked.connect(self.close) + connectWeakly(self.closeWindowButton.clicked, self, 'close') for button in ( self.addButton, diff --git a/Furious/Backends/Xray/TlsEditor.py b/Furious/Backends/Xray/TlsEditor.py index e28ad521..3aaf7157 100644 --- a/Furious/Backends/Xray/TlsEditor.py +++ b/Furious/Backends/Xray/TlsEditor.py @@ -27,8 +27,6 @@ from Furious.Backends.Configuration import * from PySide6 import QtCore from PySide6.QtWidgets import * -from typing import Callable - __all__ = ['GuiVTLSQGroupBox'] STREAM_SECURITY = [ @@ -543,12 +541,12 @@ class GuiVTLSPageXXX(GuiEditorWidgetQWidget): if isinstance(security, GuiEditorItemTextComboBox): security.setText(text) - def connectActivated(self, func: Callable): + def connectActivated(self, receiver, methodName: str): """Connect activated.""" security = self._containers[0] if isinstance(security, GuiEditorItemTextComboBox): - security.connectActivated(func) + security.connectActivated(receiver, methodName) class GuiVTLSPageEmpty(GuiVTLSPageXXX): @@ -706,10 +704,10 @@ class GuiVTLSPageStackedWidget(QStackedWidget): """Return the page value.""" return self._pages[index] - def connectActivated(self, func: Callable): + def connectActivated(self, receiver, methodName: str): """Connect activated.""" for page in self._pages: - page.connectActivated(func) + page.connectActivated(receiver, methodName) class GuiVTLSQGroupBox(EditorBinding, AppQGroupBox): @@ -724,7 +722,7 @@ class GuiVTLSQGroupBox(EditorBinding, AppQGroupBox): self._config = CoreConfiguration() self._widget = GuiVTLSPageStackedWidget() - self._widget.connectActivated(self.handleActivated) + self._widget.connectActivated(self, 'handleActivated') layout = QFormLayout() layout.addRow(self._widget) diff --git a/Furious/Backends/Xray/TransportEditor.py b/Furious/Backends/Xray/TransportEditor.py index 0d598524..573d6e1c 100644 --- a/Furious/Backends/Xray/TransportEditor.py +++ b/Furious/Backends/Xray/TransportEditor.py @@ -27,8 +27,6 @@ from Furious.Backends.Configuration import * from PySide6.QtWidgets import * -from typing import Callable - __all__ = ['GuiVTransportQGroupBox'] STREAM_NETWORK = [ @@ -1618,12 +1616,12 @@ class GuiVTransportPageXXX(GuiEditorWidgetQWidget): return '' - def connectActivated(self, func: Callable): + def connectActivated(self, receiver, methodName: str): """Connect activated.""" network = self._containers[0] if isinstance(network, GuiEditorItemTextComboBox): - network.connectActivated(func) + network.connectActivated(receiver, methodName) class GuiVTransportPageTcp(GuiVTransportPageXXX): @@ -1879,10 +1877,10 @@ class GuiVTransportPageStackedWidget(QStackedWidget): """Return the page value.""" return self._pages[index] - def connectActivated(self, func: Callable): + def connectActivated(self, receiver, methodName: str): """Connect activated.""" for page in self._pages: - page.connectActivated(func) + page.connectActivated(receiver, methodName) class GuiVTransportQGroupBox(EditorBinding, AppQGroupBox): @@ -1895,7 +1893,7 @@ class GuiVTransportQGroupBox(EditorBinding, AppQGroupBox): self._config = CoreConfiguration() self._widget = GuiVTransportPageStackedWidget() - self._widget.connectActivated(self.handleActivated) + self._widget.connectActivated(self, 'handleActivated') layout = QFormLayout() layout.addRow(self._widget) diff --git a/Furious/Backends/Xray/TunSettingsDialog.py b/Furious/Backends/Xray/TunSettingsDialog.py index f49b35b7..11a86160 100644 --- a/Furious/Backends/Xray/TunSettingsDialog.py +++ b/Furious/Backends/Xray/TunSettingsDialog.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Qt import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt import gettext as _ from PySide6 import QtCore @@ -171,7 +172,7 @@ class XrayTUNDocumentationURL(AppQLabel): super().__init__(*args, **kwargs) self.setWebsiteURL() - self.linkActivated.connect(self.handleLinkActivated) + connectWeakly(self.linkActivated, self, 'handleLinkActivated') def setWebsiteURL(self): """Set the translated documentation hyperlink text.""" @@ -244,7 +245,7 @@ class XrayTunSettingsDialog(GuiEditorWidgetQDialog): self.factoryToInput(self._settings) - self.accepted.connect(self.handleAccepted) + connectWeakly(self.accepted, self, 'handleAccepted') self.layout().takeRow(self.layout().rowCount() - 1) diff --git a/Furious/Backends/Xray/VlessEditor.py b/Furious/Backends/Xray/VlessEditor.py index d4ddf4af..58135d5f 100644 --- a/Furious/Backends/Xray/VlessEditor.py +++ b/Furious/Backends/Xray/VlessEditor.py @@ -23,6 +23,7 @@ from Furious.Frozenlib import * from Furious.Interface import * from Furious.Models import * from Furious.Qt import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt import gettext as _ from Furious.Backends.Configuration import * from Furious.Backends.Xray.TlsEditor import * @@ -139,7 +140,7 @@ class GuiVLESSItemBasicId(GuiEditorItemTextInput): super().__init__(*args, **kwargs) self.generateButton = AppQPushButton(_('Generate')) - self.generateButton.clicked.connect(self.generateUUID) + connectWeakly(self.generateButton.clicked, self, 'generateUUID') self._widget = QWidget() diff --git a/Furious/Backends/Xray/VmessEditor.py b/Furious/Backends/Xray/VmessEditor.py index 1a1a9822..850618c2 100644 --- a/Furious/Backends/Xray/VmessEditor.py +++ b/Furious/Backends/Xray/VmessEditor.py @@ -23,6 +23,7 @@ from Furious.Frozenlib import * from Furious.Interface import * from Furious.Models import * from Furious.Qt import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt import gettext as _ from Furious.Backends.Configuration import * from Furious.Backends.Xray.TlsEditor import * @@ -139,7 +140,7 @@ class GuiVMessItemBasicId(GuiEditorItemTextInput): super().__init__(*args, **kwargs) self.generateButton = AppQPushButton(_('Generate')) - self.generateButton.clicked.connect(self.generateUUID) + connectWeakly(self.generateButton.clicked, self, 'generateUUID') self._widget = QWidget() diff --git a/Furious/Qt/AGENTS.md b/Furious/Qt/AGENTS.md index 5ff38fe7..787af3eb 100644 --- a/Furious/Qt/AGENTS.md +++ b/Furious/Qt/AGENTS.md @@ -30,6 +30,9 @@ Use the `manage-qt-pyside6-lifetimes` skill for any Qt ownership or lifecycle ch it is redundant but harmless. Keep both registries balanced if either implementation changes. - `AppQTransientDialog` is delete-on-close. Connect completion before `open()` and never access it after destruction. Reusable dialogs/windows instead need a durable owner and must not be delete-on-close. +- In Nuitka/PySide6 builds, compiled bound methods passed directly to `SignalInstance.connect()` may be protected for the + process lifetime. Connect signals to transient receivers with `connectWeakly(signal, receiver, 'methodName')`; do not + replace this with a closure that strongly captures the receiver. - `AppQAction.callback` is a deliberate strong reference; owner scope must be no longer than the callback receiver. Application-lifetime actions cannot capture transient bound methods. - Parent child widgets/models/delegates/timers where their lifetimes match. Explicitly stop/disconnect timers, remove diff --git a/Furious/Qt/EditorWidgets.py b/Furious/Qt/EditorWidgets.py index 8517394a..f93b73df 100644 --- a/Furious/Qt/EditorWidgets.py +++ b/Furious/Qt/EditorWidgets.py @@ -24,11 +24,12 @@ from Furious.Interface import * from Furious.Models import CoreConfiguration, ServerProfile from Furious.Qt.DynamicTranslate import gettext as _ from Furious.Qt.QtWidgets import * +from Furious.Qt.Signals import connectWeakly from PySide6 import QtCore from PySide6.QtWidgets import * -from typing import Callable, Sequence +from typing import Sequence __all__ = [ 'addEditorGridBinding', @@ -181,9 +182,9 @@ class GuiEditorItemTextComboBox(EditorWidgetBinding): """Add items.""" self._input.addItems(texts) - def connectActivated(self, func: Callable): + def connectActivated(self, receiver, methodName: str): """Connect activated.""" - self._input.activated.connect(func) + connectWeakly(self._input.activated, receiver, methodName) def widgets(self): """Return the widgets owned by this editor item.""" @@ -455,8 +456,8 @@ class GuiEditorWidgetQDialog(EditorBinding, AppQTransientDialog): 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') layout = QFormLayout() layout.addRow(self.tabWidget) @@ -495,7 +496,7 @@ class GuiEditorWidgetQDialog(EditorBinding, AppQTransientDialog): def closeEvent(self, event): """Handle closure of the GUI editor widget Qt dialog.""" # Preserve QDialog's rejection/finished lifecycle for a title-bar close. - # Server-editor callbacks disconnect themselves from the rejected signal. + # Qt removes the server-table connections with this transient sender. super().closeEvent(event) def inputToFactory(self, config: dict) -> bool: diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 586b4bbf..9613ccdd 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -25,6 +25,7 @@ from Furious.Qt.AppStyleSheet import * from Furious.Qt.DynamicTheme import * from Furious.Qt.DynamicTranslate import gettext as _ from Furious.Qt.QtGui import * +from Furious.Qt.Signals import connectWeakly from PySide6 import QtCore from PySide6.QtGui import * @@ -112,7 +113,7 @@ class AppQSwitch(Mixins.ThemeAware, QCheckBox): self._animation.setDuration(self.AnimationDuration) self._animation.setEasingCurve(QtCore.QEasingCurve.Type.OutCubic) - self.toggled.connect(self._animateToggle) + connectWeakly(self.toggled, self, '_animateToggle') def _getThumbPosition(self) -> float: """Return the normalized thumb position used by the animation.""" @@ -327,12 +328,21 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog): ) _openDialogs = {} + RETAIN_UNTIL_DESTROYED = False @staticmethod def _releaseOpenDialog(key, *_args): - """Release an asynchronously opened dialog after it finishes.""" + """Release an asynchronously opened dialog after its owned lifetime.""" AppQDialog._openDialogs.pop(key, None) + @staticmethod + def _scheduleOpenDialogRelease(key, *_args): + """Release an async wrapper after the current Qt signal dispatch.""" + QtCore.QTimer.singleShot( + 0, + functools.partial(AppQDialog._releaseOpenDialog, key), + ) + def __init__(self, *args, **kwargs): """Initialize the AppQDialog.""" super().__init__(*args, **kwargs) @@ -343,16 +353,21 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog): # evict that newer dialog from the asynchronous lifetime registry. self._lifetimeKey = object() - # Do not store a nested closure that captures this dialog on the dialog - # itself. Such a self-cycle delays wrapper collection and is especially - # costly for widget trees in compiled builds. - release = functools.partial( - AppQDialog._releaseOpenDialog, + # Signal callbacks carry only the lifetime token, never this dialog. + # That avoids a Python self-cycle around the complete native widget tree. + # A reusable QDialog merely hides when it finishes, so its async owner + # can release it after that signal dispatch. A delete-on-close dialog is + # different: finished precedes deferred native destruction. Keep that + # wrapper strongly owned until destruction has fully returned to Qt. + scheduleRelease = functools.partial( + AppQDialog._scheduleOpenDialogRelease, self._lifetimeKey, ) - self.finished.connect(release) - self.destroyed.connect(release) + if not self.RETAIN_UNTIL_DESTROYED: + self.finished.connect(scheduleRelease) + + self.destroyed.connect(scheduleRelease) self.setWindowIcon(AppHue.currentWindowIcon()) @@ -422,6 +437,8 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog): class AppQTransientDialog(AppQDialog): """Present a one-shot dialog that destroys its Qt object when closed.""" + RETAIN_UNTIL_DESTROYED = True + def __init__(self, *args, **kwargs): """Initialize a dialog whose accepted/rejected lifetime is transient.""" super().__init__(*args, **kwargs) @@ -955,9 +972,17 @@ class AppQMessageBox(AppQTransientDialog): @staticmethod def _releaseOpenMessageBox(key, *_args): - """Release an asynchronously opened message box after it finishes.""" + """Release an asynchronously opened message box after destruction.""" AppQMessageBox._openMessageBoxes.pop(key, None) + @staticmethod + def _scheduleOpenMessageBoxRelease(key, *_args): + """Release an async message box after destroyed-signal dispatch.""" + QtCore.QTimer.singleShot( + 0, + functools.partial(AppQMessageBox._releaseOpenMessageBox, key), + ) + def __init__(self, *args, **kwargs): """Initialize the message box while preserving QMessageBox arguments.""" windowTitle = kwargs.pop('windowTitle', None) @@ -1001,13 +1026,14 @@ class AppQMessageBox(AppQTransientDialog): self._clickedButton = None self._handlingButton = False - release = functools.partial( - AppQMessageBox._releaseOpenMessageBox, + scheduleRelease = functools.partial( + AppQMessageBox._scheduleOpenMessageBoxRelease, self._lifetimeKey, ) - self.finished.connect(release) - self.destroyed.connect(release) + # Message boxes are delete-on-close. Their asynchronous owner releases + # them only after native destruction and its signal dispatch complete. + self.destroyed.connect(scheduleRelease) self.setObjectName('AppMessageBox') @@ -1141,7 +1167,13 @@ class AppQMessageBox(AppQTransientDialog): button = QPushButton(str(text), self.buttonFrame) button.setMinimumHeight(34) button.setAttribute(QtCore.Qt.WidgetAttribute.WA_LayoutUsesWidgetRect) - button.clicked.connect(self._handleButtonClicked) + connectWeakly( + button.clicked, + self, + '_handleButtonClicked', + sender=button, + forwardSender=True, + ) self._buttonRoles[button] = role @@ -1399,7 +1431,13 @@ class AppQMessageBox(AppQTransientDialog): customButton = button customButton.setParent(self.buttonFrame) customButton.setAttribute(QtCore.Qt.WidgetAttribute.WA_LayoutUsesWidgetRect) - customButton.clicked.connect(self._handleButtonClicked) + connectWeakly( + customButton.clicked, + self, + '_handleButtonClicked', + sender=customButton, + forwardSender=True, + ) self._buttonRoles[customButton] = role self._rebuildButtonLayout() @@ -1411,11 +1449,9 @@ class AppQMessageBox(AppQTransientDialog): return self._createButton(button, role) - @QtCore.Slot(bool) - def _handleButtonClicked(self, _checked=False): + @QtCore.Slot(object, bool) + def _handleButtonClicked(self, button, _checked=False): """Dispatch a child button through sender-based QObject ownership.""" - button = self.sender() - if isinstance(button, QAbstractButton): self._buttonWasClicked(button) diff --git a/Furious/Qt/Signals.py b/Furious/Qt/Signals.py new file mode 100644 index 00000000..436a562d --- /dev/null +++ b/Furious/Qt/Signals.py @@ -0,0 +1,109 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide signal connections that do not retain transient receivers.""" + +from __future__ import annotations + +from PySide6 import QtCore + +from shiboken6 import isValid + +from typing import Any + +import weakref + +__all__ = ['connectWeakly'] + + +def _ownsQObject(owner, object_) -> bool: + """Return whether *object_* belongs to *owner*'s QObject tree.""" + current = object_ + + while isinstance(current, QtCore.QObject): + if current is owner: + return True + + current = current.parent() + + return False + + +def connectWeakly( + signal, + receiver: Any, + methodName: str, + *, + sender=None, + forwardSender: bool = False, +): + """Connect without strongly owning a transient receiver or sender.""" + # A plain dispatcher is intentional. Nuitka's PySide6 compatibility layer + # process-globally protects compiled bound methods passed directly to connect(). + if not isinstance(methodName, str) or not methodName: + raise ValueError('method name must be a non-empty string') + + if forwardSender and sender is None: + raise ValueError('forwarding requires an explicit sender') + + receiverReference = weakref.ref(receiver) + senderReference = weakref.ref(sender) if sender is not None else None + + def invoke(*args, **kwargs): + """Invoke the named method while its Python and Qt owners remain valid.""" + currentReceiver = receiverReference() + + if currentReceiver is None: + return None + + if isinstance(currentReceiver, QtCore.QObject) and not isValid(currentReceiver): + return None + + method = getattr(currentReceiver, methodName) + + if not forwardSender: + return method(*args, **kwargs) + + currentSender = senderReference() + + if currentSender is None: + return None + + if isinstance(currentSender, QtCore.QObject) and not isValid(currentSender): + return None + + return method(currentSender, *args, **kwargs) + + connection = signal.connect(invoke) + + if ( + isinstance(receiver, QtCore.QObject) + and isinstance(sender, QtCore.QObject) + and not _ownsQObject(receiver, sender) + ): + + def disconnect(*_args): + """Remove weak dispatch from an independently owned sender.""" + try: + signal.disconnect(invoke) + except (RuntimeError, TypeError): + # The signal owner or connection was already destroyed. + pass + + receiver.destroyed.connect(disconnect) + + return connection diff --git a/Furious/Qt/__init__.py b/Furious/Qt/__init__.py index 5dbe418b..bf2dedbe 100644 --- a/Furious/Qt/__init__.py +++ b/Furious/Qt/__init__.py @@ -52,6 +52,7 @@ from .QtGui import ( bootstrapIconWithOpacity, ) from .QtNetwork import AppQNetworkAccessManager +from .Signals import connectWeakly from .QtWidgets import ( AppQComboBox, AppQComboBoxSeparatorDelegate, @@ -164,6 +165,7 @@ __all__ = [ 'bootstrapIconWhite', 'bootstrapIconWithOpacity', 'configureEditorLogMetadata', + 'connectWeakly', 'gettext', 'moveToCenter', 'showMBoxDirectRulesNotAllowed', diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 66cab0e1..cbe8d3de 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -30,6 +30,7 @@ from Furious.Plugins import ( profileFromAny, ) from Furious.Qt import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt import gettext as _ from Furious.Service import ( ConnectionManager, @@ -1601,12 +1602,6 @@ class ServerTableView( factory, parent=self, **kwargs ) - if editor is not None: - # These editors are created for a single add/edit operation. A Qt - # parent alone would keep every closed native widget tree alive for - # the lifetime of the server table. - editor.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) - return editor @QtCore.Slot(QtCore.QModelIndex) @@ -1639,18 +1634,29 @@ class ServerTableView( logger.error(f'error while converting factory to input: {ex}') - # Keep operation metadata on the transient editor and use QObject-bound - # slots. Partials stored by PySide otherwise retain both the complete - # editor tree and this application-lifetime table until disconnection. + # Keep operation metadata on the transient editor. Weak dispatch avoids + # process-lifetime protection of compiled table methods in Nuitka while + # forwarding the transient sender explicitly to the operation slot. guiEditor._modContext = (index, factory) - guiEditor.accepted.connect(self.handleGuiEditorAccepted) - guiEditor.rejected.connect(self.handleGuiEditorRejected) + connectWeakly( + guiEditor.accepted, + self, + 'handleGuiEditorAccepted', + sender=guiEditor, + forwardSender=True, + ) + connectWeakly( + guiEditor.rejected, + self, + 'handleGuiEditorRejected', + sender=guiEditor, + forwardSender=True, + ) guiEditor.open() - @QtCore.Slot() - def handleGuiEditorAccepted(self): + @QtCore.Slot(object) + def handleGuiEditorAccepted(self, editor): """Handle GUI editor accepted.""" - editor = self.sender() if not isinstance(editor, GuiEditorWidgetQDialog): return @@ -1667,22 +1673,15 @@ class ServerTableView( if modified and index == Storage.UserActivatedItemIndex(): showMBoxNewChangesNextTime() - editor.accepted.disconnect() - editor.rejected.disconnect() - del editor._modContext - @QtCore.Slot() - def handleGuiEditorRejected(self): + @QtCore.Slot(object) + def handleGuiEditorRejected(self, editor): """Handle GUI editor rejected.""" - editor = self.sender() if not isinstance(editor, GuiEditorWidgetQDialog): return - editor.accepted.disconnect() - editor.rejected.disconnect() - if hasattr(editor, '_modContext'): del editor._modContext @@ -1809,14 +1808,25 @@ class ServerTableView( logger.error(f'error while converting factory to input: {ex}') guiEditor._addContext = factory - guiEditor.accepted.connect(self.handleAddServerViaGuiAccepted) - guiEditor.rejected.connect(self.handleAddServerViaGuiRejected) + connectWeakly( + guiEditor.accepted, + self, + 'handleAddServerViaGuiAccepted', + sender=guiEditor, + forwardSender=True, + ) + connectWeakly( + guiEditor.rejected, + self, + 'handleAddServerViaGuiRejected', + sender=guiEditor, + forwardSender=True, + ) guiEditor.open() - @QtCore.Slot() - def handleAddServerViaGuiAccepted(self): + @QtCore.Slot(object) + def handleAddServerViaGuiAccepted(self, editor): """Handle add server via GUI accepted.""" - editor = self.sender() if not isinstance(editor, GuiEditorWidgetQDialog): return @@ -1827,22 +1837,15 @@ class ServerTableView( self.appendNewItemByFactory(factory) - editor.accepted.disconnect() - editor.rejected.disconnect() - del editor._addContext - @QtCore.Slot() - def handleAddServerViaGuiRejected(self): + @QtCore.Slot(object) + def handleAddServerViaGuiRejected(self, editor): """Handle add server via GUI rejected.""" - editor = self.sender() if not isinstance(editor, GuiEditorWidgetQDialog): return - editor.accepted.disconnect() - editor.rejected.disconnect() - if hasattr(editor, '_addContext'): del editor._addContext diff --git a/Furious/Window/IndentDialog.py b/Furious/Window/IndentDialog.py index cf16305e..658fb9c2 100644 --- a/Furious/Window/IndentDialog.py +++ b/Furious/Window/IndentDialog.py @@ -20,6 +20,7 @@ from __future__ import annotations from Furious.Qt import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt import gettext as _ from PySide6 import QtCore @@ -48,8 +49,8 @@ class IndentDialog(AppQTransientDialog): 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') layout = QFormLayout() layout.addRow(self.indentText, self.indentSpin) diff --git a/Furious/Window/SubscriptionPage.py b/Furious/Window/SubscriptionPage.py index 3926e293..06da9c93 100644 --- a/Furious/Window/SubscriptionPage.py +++ b/Furious/Window/SubscriptionPage.py @@ -35,6 +35,7 @@ from Furious.Qt import ( bootstrapIconWhite, ) from Furious.Qt import gettext as _ +from Furious.Qt.Signals import connectWeakly from Furious.Repository import Storage from Furious.Widget.SubscriptionTableView import SubscriptionTableView @@ -133,8 +134,8 @@ class _SubscriptionEditorDialog(AppQTransientDialog): _('Cancel'), AppQDialogButtonBox.ButtonRole.RejectRole, ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) + connectWeakly(self.buttons.accepted, self, 'accept') + connectWeakly(self.buttons.rejected, self, 'reject') formWidget = QFrame() formWidget.setObjectName('SubscriptionEditorForm') diff --git a/Furious/Window/TunSettingsDialog.py b/Furious/Window/TunSettingsDialog.py index 3a5acec2..f20578c7 100644 --- a/Furious/Window/TunSettingsDialog.py +++ b/Furious/Window/TunSettingsDialog.py @@ -23,6 +23,7 @@ from Furious.Frozenlib import * from Furious.Interface import * from Furious.Repository import * from Furious.Qt import * +from Furious.Qt.Signals import connectWeakly from Furious.Qt import gettext as _ from PySide6 import QtCore @@ -147,7 +148,7 @@ class AppQLabelHelpPage(AppQLabel): super().__init__(*args, **kwargs) self.setWebsiteURL() - self.linkActivated.connect(self.handleLinkActivated) + connectWeakly(self.linkActivated, self, 'handleLinkActivated') def setWebsiteURL(self): """Set website URL.""" @@ -315,8 +316,8 @@ class TunSettingsDialog(GuiEditorWidgetQDialog): logger.error(f'error while converting factory to input: {ex}') - self.accepted.connect(self.handleAccepted) - self.rejected.connect(self.handleRejected) + connectWeakly(self.accepted, self, 'handleAccepted') + connectWeakly(self.rejected, self, 'handleRejected') @QtCore.Slot() def handleAccepted(self): diff --git a/tests/fixtures/editor_lifetime_probe.py b/tests/fixtures/editor_lifetime_probe.py new file mode 100644 index 00000000..80c9ef3d --- /dev/null +++ b/tests/fixtures/editor_lifetime_probe.py @@ -0,0 +1,241 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Exercise server-table editor ownership in source or compiled form.""" + +from __future__ import annotations + +from Furious.Backends import OFFICIAL_PLUGIN_TYPES +from Furious.Plugins import blankProfile, initializePluginRegistry +from Furious.Qt import AppQDialog, connectWeakly +from Furious.Widget.ServerTableView import ServerTableView + +import PySide6 + +from PySide6.QtWidgets import QWidget + +from shiboken6 import isValid + +from tests.support import ( + application, + collectAtBoundary, + isolatedSettings, + processQtEvents, +) + +from collections import Counter + +import argparse +import json +import weakref + +PROTOCOL_PATTERNS = { + 'alternating': ('hysteria2', 'vless'), + 'reverse': ('vless', 'hysteria2'), + 'hysteria2': ('hysteria2',), + 'vless': ('vless',), + 'representative': ( + 'hysteria2', + 'vless', + 'vmess', + 'trojan', + 'socks', + 'hysteria1', + 'external-core', + ), +} +CLOSE_METHODS = ('accept', 'close', 'reject') + + +def runProbe( + iterations: int = 100, + *, + pattern: str = 'alternating', + closeMethod: str = 'reject', +) -> dict[str, object]: + """Open real server-table editors and prove deterministic destruction.""" + application() + + if pattern not in PROTOCOL_PATTERNS: + raise ValueError(f'unsupported editor pattern: {pattern!r}') + + if closeMethod not in CLOSE_METHODS: + raise ValueError(f'unsupported close method: {closeMethod!r}') + + protocols = PROTOCOL_PATTERNS[pattern] + references = [] + destroyed = [] + finishedBeforeDestroyed = [] + operationContextsReleased = [] + invalidWrappersAfterClose = 0 + protocolCounts = Counter() + + with isolatedSettings(): + registry = initializePluginRegistry(OFFICIAL_PLUGIN_TYPES) + table = ServerTableView( + configurationEditorFactory=QWidget, + qrCodeWindowFactory=QWidget, + importActionsFactory=tuple, + ) + + protectedMethods = getattr(PySide6, '_protected', None) + protectedMethodsBefore = ( + len(protectedMethods) if isinstance(protectedMethods, list) else None + ) + + try: + for index in range(iterations): + protocol = protocols[index % len(protocols)] + protocolCounts[protocol] += 1 + + profile = blankProfile(protocol, registry=registry) + editor = table.getGuiEditorByFactory(profile, translatable=False) + + if editor is None: + raise RuntimeError(f'no editor is registered for {protocol!r}') + + editor.factoryToInput(profile) + + key = editor._lifetimeKey + reference = weakref.ref(editor) + + editor._modContext = (index, profile) + connectWeakly( + editor.accepted, + table, + 'handleGuiEditorAccepted', + sender=editor, + forwardSender=True, + ) + connectWeakly( + editor.rejected, + table, + 'handleGuiEditorRejected', + sender=editor, + forwardSender=True, + ) + editor.finished.connect( + lambda _result, _key=key: finishedBeforeDestroyed.append( + _key in AppQDialog._openDialogs + ) + ) + editor.finished.connect( + lambda _result, _reference=reference: ( + operationContextsReleased.append( + _reference() is not None + and not hasattr(_reference(), '_modContext') + ) + ) + ) + editor.destroyed.connect( + lambda *_args, _destroyed=destroyed: _destroyed.append(True) + ) + + references.append(reference) + + editor.open() + del editor + + processQtEvents(1) + + activeEditor = reference() + + if activeEditor is None or not isValid(activeEditor): + raise RuntimeError(f'{protocol} editor died while still open') + + getattr(activeEditor, closeMethod)() + del activeEditor + + processQtEvents(2) + + if reference() is not None and not isValid(reference()): + invalidWrappersAfterClose += 1 + + collectAtBoundary() + + result = { + 'iterations': iterations, + 'pattern': pattern, + 'closeMethod': closeMethod, + 'protocolCounts': dict(sorted(protocolCounts.items())), + 'destroyed': len(destroyed), + 'liveWrappers': sum( + reference() is not None for reference in references + ), + 'openDialogs': len(AppQDialog._openDialogs), + 'registryHeldAtFinished': sum(finishedBeforeDestroyed), + 'operationContextsReleased': sum(operationContextsReleased), + 'invalidWrappersAfterClose': invalidWrappersAfterClose, + 'nuitkaProtectedGrowth': ( + len(protectedMethods) - protectedMethodsBefore + if protectedMethodsBefore is not None + else 0 + ), + } + + expectedCounts = Counter( + protocols[index % len(protocols)] for index in range(iterations) + ) + + if result != { + 'iterations': iterations, + 'pattern': pattern, + 'closeMethod': closeMethod, + 'protocolCounts': dict(sorted(expectedCounts.items())), + 'destroyed': iterations, + 'liveWrappers': 0, + 'openDialogs': 0, + 'registryHeldAtFinished': iterations, + 'operationContextsReleased': iterations, + 'invalidWrappersAfterClose': 0, + 'nuitkaProtectedGrowth': 0, + }: + raise RuntimeError(f'editor lifetime probe failed: {result!r}') + + return result + finally: + table.close() + table.deleteLater() + processQtEvents() + + registry.shutdown() + + +def main(): + """Run the probe as a standalone source or Nuitka executable.""" + parser = argparse.ArgumentParser() + parser.add_argument('--iterations', type=int, default=100) + parser.add_argument( + '--pattern', choices=tuple(PROTOCOL_PATTERNS), default='alternating' + ) + parser.add_argument('--close-method', choices=CLOSE_METHODS, default='reject') + arguments = parser.parse_args() + + print( + json.dumps( + runProbe( + arguments.iterations, + pattern=arguments.pattern, + closeMethod=arguments.close_method, + ), + sort_keys=True, + ) + ) + + +if __name__ == '__main__': + main() diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py index 877c13a9..9ea50ad2 100644 --- a/tests/test_qt_lifetime.py +++ b/tests/test_qt_lifetime.py @@ -41,7 +41,9 @@ from Furious.Qt import ( AppQMainWindow, AppQMenu, AppQMessageBox, + AppQSwitch, AppQTransientDialog, + connectWeakly, ) from Furious.Qt.QtWidgets import _AppMessageBoxMask from Furious.Window.QRCodeWindow import QRCodeWindow, _QRCodePage @@ -56,9 +58,11 @@ from shiboken6 import isValid from tests.support import ( application, + assertChildSucceeded, collectAtBoundary, isolatedSettings, processQtEvents, + runPythonChild, waitFor, ) @@ -100,7 +104,12 @@ class TransientReceiver(AppQTransientDialog): self._calls = calls - emitter.emitted.connect(self.handleEmission) + connectWeakly( + emitter.emitted, + self, + 'handleEmission', + sender=emitter, + ) @QtCore.Slot() def handleEmission(self): @@ -169,11 +178,15 @@ class QtLifetimeTest(unittest.TestCase): for pool, baseline in poolBaselines.items(): self.assertEqual(len(pool.ObjectsPool), baseline) - def testAsyncDialogRegistryRetainsOnlyUntilNormalClose(self): - """Prevent premature GC while open and release immediately after finish.""" + def testAsyncDialogRegistryRetainsUntilNativeDestruction(self): + """Keep a delete-on-close wrapper alive through deferred destruction.""" dialog = ProbeTransientDialog() key = dialog._lifetimeKey reference = weakref.ref(dialog) + heldAtFinished = [] + dialog.finished.connect( + lambda _result: heldAtFinished.append(key in AppQDialog._openDialogs) + ) dialog.open() del dialog @@ -183,7 +196,10 @@ class QtLifetimeTest(unittest.TestCase): self.assertIsNotNone(reference()) self.assertIn(key, AppQDialog._openDialogs) - reference().close() + reference().reject() + + self.assertEqual(heldAtFinished, [True]) + self.assertIn(key, AppQDialog._openDialogs) collectAtBoundary() @@ -271,10 +287,13 @@ class QtLifetimeTest(unittest.TestCase): collectAtBoundary() self.assertTrue(all(reference() is None for reference in oldReferences)) + self.assertEqual(emitter.receivers(QtCore.SIGNAL('emitted()')), 0) current = TransientReceiver(emitter, calls) current.show() + self.assertEqual(emitter.receivers(QtCore.SIGNAL('emitted()')), 1) + emitter.emitted.emit() processQtEvents() @@ -285,8 +304,56 @@ class QtLifetimeTest(unittest.TestCase): del current + collectAtBoundary() + + self.assertEqual(emitter.receivers(QtCore.SIGNAL('emitted()')), 0) + emitter.deleteLater() + def testHysteria2SwitchAnimationStopsWithTransientEditor(self): + """Destroy owned switch animations even when a toggle just started.""" + references = [] + destroyed = [] + + for _index in range(30): + editor = Hysteria2Editor() + switches = editor.findChildren(AppQSwitch) + + self.assertTrue(switches) + + switch = switches[0] + switch.setChecked(not switch.isChecked()) + + references.extend((weakref.ref(switch), weakref.ref(switch._animation))) + editor.destroyed.connect(lambda *_args: destroyed.append(True)) + editor.show() + editor.close() + + del editor, switch, switches + + collectAtBoundary() + + self.assertTrue(all(reference() is None for reference in references)) + self.assertEqual(len(destroyed), 30) + + def testServerTableEditorSequenceIsTransientAcrossPatternsAndClosePaths(self): + """Stress the real table/editor path without per-cycle full collection.""" + result = runPythonChild( + """ +from tests.fixtures.editor_lifetime_probe import runProbe + +for pattern in ('hysteria2', 'vless', 'alternating', 'reverse'): + runProbe(100, pattern=pattern, closeMethod='reject') + +runProbe(40, pattern='alternating', closeMethod='close') +runProbe(40, pattern='reverse', closeMethod='accept') +runProbe(35, pattern='representative', closeMethod='close') +""", + timeout=120, + ) + + assertChildSucceeded(self, result, 'server editor lifetime child') + def testRepresentativePluginEditorsAndDialogsAreTransient(self): """Destroy independent editor families after repeated normal closes.""" factories = (