Fix transient editor lifetimes in packaged builds

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-24 05:30:05 +08:00
parent db7d2dd055
commit 2b5db889cf
21 changed files with 593 additions and 121 deletions
+7 -5
View File
@@ -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)
+2 -1
View File
@@ -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."""
+11 -8
View File
@@ -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."""
@@ -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)
+14 -13
View File
@@ -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,
+5 -7
View File
@@ -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)
+5 -7
View File
@@ -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)
+3 -2
View File
@@ -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)
+2 -1
View File
@@ -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()
+2 -1
View File
@@ -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()
+3
View File
@@ -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
+7 -6
View File
@@ -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:
+56 -20
View File
@@ -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)
+109
View File
@@ -0,0 +1,109 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""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
+2
View File
@@ -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',
+40 -37
View File
@@ -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
+3 -2
View File
@@ -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)
+3 -2
View File
@@ -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')
+4 -3
View File
@@ -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):
+241
View File
@@ -0,0 +1,241 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""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()
+71 -4
View File
@@ -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 = (