mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-12 01:08:17 +03:00
Fix transient editor lifetimes in packaged builds
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user