Completed the repository-wide Qt/PySide6 lifetime audit

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-14 10:16:55 +08:00
parent a20071f49b
commit e914e98fb0
13 changed files with 232 additions and 143 deletions
+3
View File
@@ -486,6 +486,9 @@ class ImportQRCodeOnTheScreenAction(Mixins.CleanupOnExit, AppQAction):
super().__init__(
_('Scan QR Code On The Screen'),
icon=bootstrapIcon('qr-code-scan.svg'),
# Tray and page actions each own a distinct native screen-capture
# handle, so every instance must close its own handle at shutdown.
uniqueCleanup=False,
**kwargs,
)
+29 -8
View File
@@ -49,7 +49,6 @@ import sys
import logging
import platform
import threading
import functools
import traceback
import darkdetect
@@ -199,19 +198,33 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
self._userTUNSettings = None
# ThreadPool
self.threadPool = QtCore.QThreadPool()
self.threadPool = QtCore.QThreadPool(self)
self.threadPool.setMaxThreadCount(max(OS_CPU_COUNT // 2, 1))
@callRateLimited(maxCallPerSecond=2)
@QtCore.Slot()
def handleNewConnection(self):
"""Handle new connection."""
socket = self.server.nextPendingConnection()
socket.readyRead.connect(functools.partial(self.handleNewData, socket))
while self.server.hasPendingConnections():
socket = self.server.nextPendingConnection()
@QtCore.Slot(QLocalSocket)
def handleNewData(self, socket: QLocalSocket):
if socket is None:
continue
# QLocalServer owns pending sockets until they are explicitly
# released. Use sender() instead of a partial that retains each
# socket and dispose it after the one-command protocol completes.
socket.readyRead.connect(self.handleNewData)
socket.disconnected.connect(socket.deleteLater)
@QtCore.Slot()
def handleNewData(self):
"""Handle new data."""
socket = self.sender()
if not isinstance(socket, QLocalSocket):
return
data = socket.readAll().data()
if isinstance(data, bytes):
@@ -235,6 +248,14 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
# TODO: Not implemented
pass
# The singleton IPC channel carries exactly one command. Closing it
# here both wakes RunAs clients and prevents completed QLocalSocket
# children from accumulating under the application-wide server.
socket.disconnectFromServer()
if socket.state() == QLocalSocket.LocalSocketState.UnconnectedState:
socket.deleteLater()
def configureLogging(self):
"""Configure logging."""
self.logManager = LogManager(parent=self)
@@ -528,7 +549,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
self.handleSystemThemeChanged(currentTheme)
self.currentTheme = self.systemTheme()
self.themeDetectTimer = QtCore.QTimer()
self.themeDetectTimer = QtCore.QTimer(self)
self.themeDetectTimer.timeout.connect(handleTimeout)
self.themeDetectTimer.start(1000)
else:
@@ -545,7 +566,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
'darkdetect listener is not implemented on this platform'
)
self.themeDetector = ApplicationThemeDetector()
self.themeDetector = ApplicationThemeDetector(self)
self.themeDetector.themeChanged.connect(self.handleSystemThemeChanged)
self.themeListenerThread = threading.Thread(
+25 -8
View File
@@ -696,17 +696,26 @@ class RoutingRulesQListWidget(AppQListWidget):
self.setSelectionMode(AppQListWidget.SelectionMode.ExtendedSelection)
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
self.itemDoubleClicked.connect(lambda _item: self.editRequested.emit())
self.itemDoubleClicked.connect(self._requestEdit)
self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested)
self.contextDeleteAction = AppQAction(
_('Delete'),
callback=lambda: self.deleteRequested.emit(),
)
self.contextDeleteAction = AppQAction(_('Delete'))
self.contextDeleteAction.triggered.connect(self._requestDelete)
self.contextMenu = AppQMenu(self.contextDeleteAction, parent=self)
self.flushAll()
@QtCore.Slot(QListWidgetItem)
def _requestEdit(self, _item):
"""Forward a row double-click without retaining a Python closure."""
self.editRequested.emit()
@QtCore.Slot(bool)
def _requestDelete(self, _checked=False):
"""Request deletion without retaining a callback closure."""
self.deleteRequested.emit()
def rules(self):
"""Return the rules value used by the routing rules Qt list widget."""
rules = self.routing.setdefault('rules', list())
@@ -824,7 +833,10 @@ class RoutingRulesDialog(AppQTransientDialog):
def addRule(self):
"""Add rule."""
rule = {'type': 'field', 'outboundTag': 'proxy', 'ruleTag': 'New Rule'}
dialog = RoutingRuleEditDialog(rule, parent=None)
# This editor is subordinate to the transient rules dialog. Parenting
# it prevents an asynchronous child from outliving its owner and later
# invoking a callback on a deleted list widget.
dialog = RoutingRuleEditDialog(rule, parent=self)
def handleResultCode(code):
"""Handle result code."""
@@ -842,7 +854,7 @@ class RoutingRulesDialog(AppQTransientDialog):
return
index = indexes[0]
dialog = RoutingRuleEditDialog(self.listWidget.ruleAt(index), parent=None)
dialog = RoutingRuleEditDialog(self.listWidget.ruleAt(index), parent=self)
def handleResultCode(_index, code):
"""Handle result code."""
@@ -1149,9 +1161,14 @@ class UserRoutingTableView(Mixins.QTranslatable, AppQTableView):
routing = self.sourceModel.routingByRow(indexes[0])
dialog = RoutingRulesDialog(routing, parent=self)
dialog.finished.connect(lambda _code: self.flushAll())
dialog.finished.connect(self._rulesDialogFinished)
dialog.open()
@QtCore.Slot(int)
def _rulesDialogFinished(self, _code):
"""Refresh routing presentation after the rules dialog finishes."""
self.flushAll()
class XrayRoutingWindow(AppQMainWindow):
"""Present the user routing window."""
+12 -8
View File
@@ -174,17 +174,21 @@ class AppQAction(Mixins.QTranslatable, Mixins.ThemeAware, QAction):
if shortcut is not None:
self.setShortcut(shortcut)
@QtCore.Slot(bool)
def triggerSignal(paramChecked):
"""Handle trigger signal for the app q action."""
logger.info(f'action is \'{self.textEnglish}\'. Checked is {paramChecked}')
# Connect to a real QObject method rather than a nested closure that
# captures this action. PySide stores Python callables connected to a
# signal outside the normal Python object graph; a closure here leaves
# an otherwise unowned QAction alive indefinitely.
self.triggered.connect(self._handleTriggered)
if callable(self.callback):
self.callback()
@QtCore.Slot(bool)
def _handleTriggered(self, paramChecked):
"""Dispatch activation without creating a signal/self reference cycle."""
logger.info(f'action is \'{self.textEnglish}\'. Checked is {paramChecked}')
self.triggeredCallback(paramChecked)
if callable(self.callback):
self.callback()
self.triggered.connect(triggerSignal)
self.triggeredCallback(paramChecked)
def addAction(self, action):
"""Add action."""
+31 -8
View File
@@ -187,7 +187,11 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog):
"""Initialize the AppQDialog."""
super().__init__(*args, **kwargs)
self._lifetimeKey = id(self)
# Use a per-instance token rather than id(self). A finished transient
# can release its Python wrapper before Qt processes deleteLater(); an
# ID reused by a newer dialog must not let the older destroyed signal
# evict that newer dialog from the asynchronous lifetime registry.
self._lifetimeKey = object()
self._firstShowPending = True
# Do not store a nested closure that captures this dialog on the dialog
@@ -460,7 +464,7 @@ class AppQMainWindow(
"""Initialize the AppQMainWindow."""
super().__init__(*args, **kwargs)
self._lifetimeKey = id(self)
self._lifetimeKey = object()
self._firstShowPending = True
release = functools.partial(
@@ -614,6 +618,19 @@ class _AppMessageBoxMask(QFrame):
return super().eventFilter(watched, event)
def dispose(self):
"""Detach from the long-lived owner before scheduling destruction."""
owner = self.parentWidget()
if owner is not None:
try:
owner.removeEventFilter(self)
except RuntimeError:
# The owner can be destroyed as part of the same close path.
pass
self.deleteLater()
class AppQMessageBox(AppQTransientDialog):
"""Present a responsive Fluent dialog with QMessageBox-compatible APIs."""
@@ -682,7 +699,7 @@ class AppQMessageBox(AppQTransientDialog):
super().__init__(parent=parent, **kwargs)
self._lifetimeKey = id(self)
self._lifetimeKey = object()
self._windowMask = None
self._icon = self.Icon.NoIcon
self._text = ''
@@ -814,7 +831,7 @@ class AppQMessageBox(AppQTransientDialog):
button = QPushButton(str(text), self.buttonFrame)
button.setMinimumHeight(34)
button.setAttribute(QtCore.Qt.WidgetAttribute.WA_LayoutUsesWidgetRect)
button.clicked.connect(functools.partial(self._buttonWasClicked, button))
button.clicked.connect(self._handleButtonClicked)
self._buttonRoles[button] = role
@@ -1058,9 +1075,7 @@ class AppQMessageBox(AppQTransientDialog):
customButton = button
customButton.setParent(self.buttonFrame)
customButton.setAttribute(QtCore.Qt.WidgetAttribute.WA_LayoutUsesWidgetRect)
customButton.clicked.connect(
functools.partial(self._buttonWasClicked, customButton)
)
customButton.clicked.connect(self._handleButtonClicked)
self._buttonRoles[customButton] = role
self._rebuildButtonLayout()
@@ -1072,6 +1087,14 @@ class AppQMessageBox(AppQTransientDialog):
return self._createButton(button, role)
@QtCore.Slot(bool)
def _handleButtonClicked(self, _checked=False):
"""Dispatch a child button through sender-based QObject ownership."""
button = self.sender()
if isinstance(button, QAbstractButton):
self._buttonWasClicked(button)
def removeButton(self, button):
"""Remove one custom or standard button."""
self._buttonRoles.pop(button, None)
@@ -1399,7 +1422,7 @@ class AppQMessageBox(AppQTransientDialog):
self._windowMask = None
if mask is not None:
mask.deleteLater()
mask.dispose()
def moveToCenter(self):
"""Move to center."""
+18 -15
View File
@@ -274,7 +274,7 @@ class AppQTextBrowser(SupportPointSizeSettings, QTextBrowser):
if delta > 0:
self.zoomIn()
if delta < 0:
elif delta < 0:
self.zoomOut()
else:
super().wheelEvent(event)
@@ -306,23 +306,26 @@ class DraculaTextEditor(Mixins.ThemeAware, AppQPlainTextEdit):
)
)
@QtCore.Slot(bool)
def handleModificationChanged(changed):
"""Handle modification changed."""
if changed:
self.document().setModified(False)
# QObject-bound slots are important here. Nested closures connected to
# the editor's own signals retain the complete editor widget tree in
# PySide even after its window has been destroyed.
self.modificationChanged.connect(self._handleModificationChanged)
self.cursorPositionChanged.connect(self._handleCursorPositionChanged)
if callable(self._modificationChangedCb):
self._modificationChangedCb()
@QtCore.Slot(bool)
def _handleModificationChanged(self, changed):
"""Notify the registered modification callback for a real edit."""
if changed:
self.document().setModified(False)
@QtCore.Slot()
def handleCursorPositionChanged():
"""Handle cursor position changed."""
if callable(self._cursorPositionChangedCb):
self._cursorPositionChangedCb(self.textCursor())
if callable(self._modificationChangedCb):
self._modificationChangedCb()
self.modificationChanged.connect(handleModificationChanged)
self.cursorPositionChanged.connect(handleCursorPositionChanged)
@QtCore.Slot()
def _handleCursorPositionChanged(self):
"""Notify the registered cursor-position callback."""
if callable(self._cursorPositionChangedCb):
self._cursorPositionChangedCb(self.textCursor())
def themeChangedCallback(self, theme: str):
"""Refresh editor chrome after an application theme change."""
+51 -41
View File
@@ -1891,29 +1891,24 @@ class ServerTableView(
logger.error(f'error while converting factory to input: {ex}')
guiEditor.accepted.connect(
functools.partial(
self.handleGuiEditorAccepted,
guiEditor,
index,
factory,
)
)
guiEditor.rejected.connect(
functools.partial(
self.handleGuiEditorRejected,
guiEditor,
)
)
# 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.
guiEditor._modContext = (index, factory)
guiEditor.accepted.connect(self.handleGuiEditorAccepted)
guiEditor.rejected.connect(self.handleGuiEditorRejected)
guiEditor.open()
def handleGuiEditorAccepted(
self,
editor: GuiEditorWidgetQDialog,
index: int,
factory: ServerProfile,
):
@QtCore.Slot()
def handleGuiEditorAccepted(self):
"""Handle GUI editor accepted."""
editor = self.sender()
if not isinstance(editor, GuiEditorWidgetQDialog):
return
index, factory = editor._modContext
logger.debug(f'guiEditor accepted with index {index}')
modified = editor.inputToFactory(factory)
@@ -1927,12 +1922,22 @@ class ServerTableView(
editor.accepted.disconnect()
editor.rejected.disconnect()
@staticmethod
def handleGuiEditorRejected(editor: GuiEditorWidgetQDialog):
del editor._modContext
@QtCore.Slot()
def handleGuiEditorRejected(self):
"""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
@QtCore.Slot(QtCore.QPoint)
def handleCustomContextMenuRequested(self, point):
"""Handle custom context menu requested."""
@@ -2052,27 +2057,21 @@ class ServerTableView(
logger.error(f'error while converting factory to input: {ex}')
guiEditor.accepted.connect(
functools.partial(
self.handleAddServerViaGuiAccepted,
guiEditor,
factory,
)
)
guiEditor.rejected.connect(
functools.partial(
self.handleAddServerViaGuiRejected,
guiEditor,
)
)
guiEditor._addContext = factory
guiEditor.accepted.connect(self.handleAddServerViaGuiAccepted)
guiEditor.rejected.connect(self.handleAddServerViaGuiRejected)
guiEditor.open()
def handleAddServerViaGuiAccepted(
self,
editor: GuiEditorWidgetQDialog,
factory: ServerProfile,
):
@QtCore.Slot()
def handleAddServerViaGuiAccepted(self):
"""Handle add server via GUI accepted."""
editor = self.sender()
if not isinstance(editor, GuiEditorWidgetQDialog):
return
factory = editor._addContext
editor.inputToFactory(factory)
self.appendNewItemByFactory(factory)
@@ -2080,11 +2079,22 @@ class ServerTableView(
editor.accepted.disconnect()
editor.rejected.disconnect()
def handleAddServerViaGuiRejected(self, editor: GuiEditorWidgetQDialog):
del editor._addContext
@QtCore.Slot()
def handleAddServerViaGuiRejected(self):
"""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
def flushRow(self, row: int, item: ServerProfile):
"""Refresh row."""
itemIndex = item.index
+2 -4
View File
@@ -729,10 +729,8 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
"""Handle the home-page request to restart with elevated privileges."""
if not SystemRuntime.isScriptMode():
if not SystemRuntime.isAdmin():
process = QtCore.QProcess()
if PLATFORM == 'Windows':
process.startDetached(
QtCore.QProcess.startDetached(
'powershell',
arguments=[
'-Command',
@@ -741,7 +739,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
],
)
elif PLATFORM == 'Darwin':
process.startDetached(
QtCore.QProcess.startDetached(
'osascript',
arguments=[
'-e',
+13 -8
View File
@@ -27,7 +27,6 @@ from PySide6 import QtCore
from PySide6.QtWidgets import *
import logging
import functools
__all__ = ['NetworkTestDialog']
@@ -76,13 +75,9 @@ class NetworkTestDialog(AppQDialog):
self.dialogBtns.rejected.connect(self.reject)
self.speedTestURLResetBtn = AppQPushButton(_('Reset'))
self.speedTestURLResetBtn.clicked.connect(
lambda: self.speedTestURLEdit.setText(NETWORK_SPEED_TEST_URL)
)
self.speedTestURLResetBtn.clicked.connect(self._resetSpeedTestURL)
self.connectivityResetBtn = AppQPushButton(_('Reset'))
self.connectivityResetBtn.clicked.connect(
lambda: self.connectivityEdit.setText(NETWORK_CONNECTIVITY_TEST_URL)
)
self.connectivityResetBtn.clicked.connect(self._resetConnectivityURL)
self.speedTestURLHboxLayout = QHBoxLayout()
self.speedTestURLHboxLayout.addWidget(self.speedTestURLEdit)
@@ -102,7 +97,17 @@ class NetworkTestDialog(AppQDialog):
self.setLayout(layout)
self.finished.connect(functools.partial(self.handleResultCode))
self.finished.connect(self.handleResultCode)
@QtCore.Slot()
def _resetSpeedTestURL(self):
"""Restore the default speed-test URL."""
self.speedTestURLEdit.setText(NETWORK_SPEED_TEST_URL)
@QtCore.Slot()
def _resetConnectivityURL(self):
"""Restore the default connectivity-test URL."""
self.connectivityEdit.setText(NETWORK_CONNECTIVITY_TEST_URL)
def setWidthAndHeight(self):
"""Apply the default size for the GUI customize network test dialog."""
+2 -3
View File
@@ -27,7 +27,6 @@ from PySide6 import QtCore
from PySide6.QtWidgets import *
import logging
import functools
__all__ = ['ProxyBypassDialog']
@@ -65,7 +64,7 @@ class ProxyBypassDialog(AppQDialog):
self.dialogBtns.rejected.connect(self.reject)
self.resetBtn = AppQPushButton(_('Reset'))
self.resetBtn.clicked.connect(functools.partial(self.handleResetButtonClicked))
self.resetBtn.clicked.connect(self.handleResetButtonClicked)
self.hboxLayout = QHBoxLayout()
self.hboxLayout.addWidget(self.resetBtn)
@@ -79,7 +78,7 @@ class ProxyBypassDialog(AppQDialog):
self.setLayout(layout)
self.finished.connect(functools.partial(self.handleResultCode))
self.finished.connect(self.handleResultCode)
def setWidthAndHeight(self):
"""Apply the default size for the GUI customize proxy bypass dialog."""
+6 -1
View File
@@ -229,7 +229,7 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
deleteUniqueCallback=self._deleteProfilesForSubscription,
parent=self,
)
self.table.doubleClicked.connect(lambda _index: self.editSelected())
self.table.doubleClicked.connect(self._editDoubleClicked)
self.table.groupsChanged.connect(
self.serverTable.subsManager.subscriptionsChanged.emit
)
@@ -282,6 +282,11 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
self.setIconsByTheme(APP().theme())
self.retranslate()
@QtCore.Slot(QtCore.QModelIndex)
def _editDoubleClicked(self, _index):
"""Edit the selected subscription after a table double-click."""
self.editSelected()
def _deleteProfilesForSubscription(self, unique: str):
"""Remove profiles belonging to a deleted subscription group."""
indexes = [
+32 -32
View File
@@ -373,43 +373,43 @@ class TextEditorWindow(AppQMainWindow):
def setIndent(self):
"""Set indent."""
def handleResultCode(_indentSpinBox, code):
"""Handle result code."""
if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted):
plain = self.jsonEditor.toPlainText()
try:
jsonObject = JSONEncoder.decode(plain)
except Exception as ex:
# Any non-exit exceptions
mbox = MBoxJSONDecodeError(
icon=AppQMessageBox.Icon.Critical, parent=self
)
mbox.error = str(ex)
mbox.setWindowTitle(_('Error setting indent'))
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
mbox.setText(mbox.customText())
# Show the MessageBox asynchronously
mbox.open()
else:
text = JSONEncoder.encode(jsonObject, indent=_indentSpinBox.value())
self.setPlainText(text, False)
else:
# Do nothing
pass
indentSpinBox = IndentDialog(parent=self)
indentSpinBox.finished.connect(
functools.partial(handleResultCode, indentSpinBox)
)
indentSpinBox.finished.connect(self._indentDialogFinished)
# Show the MessageBox asynchronously
indentSpinBox.open()
@QtCore.Slot(int)
def _indentDialogFinished(self, code):
"""Apply the selected indentation without retaining the dialog."""
indentSpinBox = self.sender()
if not isinstance(indentSpinBox, IndentDialog):
return
if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted):
return
plain = self.jsonEditor.toPlainText()
try:
jsonObject = JSONEncoder.decode(plain)
except Exception as ex:
# Any non-exit exceptions
mbox = MBoxJSONDecodeError(icon=AppQMessageBox.Icon.Critical, parent=self)
mbox.error = str(ex)
mbox.setWindowTitle(_('Error setting indent'))
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
mbox.setText(mbox.customText())
# Show the MessageBox asynchronously
mbox.open()
else:
text = JSONEncoder.encode(jsonObject, indent=indentSpinBox.value())
self.setPlainText(text, False)
def showTabAndSpaces(self):
"""Show tab and spaces."""
textOption = QTextOption()
+8 -7
View File
@@ -30,7 +30,6 @@ from PySide6.QtGui import *
from PySide6.QtWidgets import *
import logging
import functools
__all__ = ['TunSettingsDialog']
@@ -307,21 +306,22 @@ class TunSettingsDialog(GuiEditorWidgetQDialog):
self.setFixedSize(int(620 * GOLDEN_RATIO), int(620))
# Shallow copy
config = Storage.UserTUNSettings()
self._config = Storage.UserTUNSettings()
try:
self.factoryToInput(config)
self.factoryToInput(self._config)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'error while converting factory to input: {ex}')
self.accepted.connect(functools.partial(self.handleAccepted, config))
self.rejected.connect(functools.partial(self.handleRejected))
self.accepted.connect(self.handleAccepted)
self.rejected.connect(self.handleRejected)
def handleAccepted(self, config: dict):
@QtCore.Slot()
def handleAccepted(self):
"""Handle accepted."""
modified = self.inputToFactory(config)
modified = self.inputToFactory(self._config)
if modified and SystemRuntime.isTUNMode():
showMBoxNewChangesNextTime()
@@ -329,6 +329,7 @@ class TunSettingsDialog(GuiEditorWidgetQDialog):
self.accepted.disconnect()
self.rejected.disconnect()
@QtCore.Slot()
def handleRejected(self):
"""Handle rejected."""
self.accepted.disconnect()