Fix shared Qt object lifetimes

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-13 16:49:38 +08:00
parent 9476665dab
commit 46dc9710b0
16 changed files with 224 additions and 121 deletions
+1 -10
View File
@@ -130,11 +130,9 @@ def importURIs(*uris, failureCallback: Union[Callable[[], None], None] = None):
mbox.open()
class ImportURIsProgressDialog(AppQDialog):
class ImportURIsProgressDialog(AppQTransientDialog):
"""Present progress and cancellation controls for import ur is."""
ActiveDialogs = list()
def __init__(
self,
uris: Tuple[str, ...],
@@ -190,8 +188,6 @@ class ImportURIsProgressDialog(AppQDialog):
def open(self):
"""Open the import ur is progress dialog asynchronously."""
ImportURIsProgressDialog.ActiveDialogs.append(self)
result = super().open()
self.spinner.start()
@@ -271,11 +267,6 @@ class ImportURIsProgressDialog(AppQDialog):
self.spinner.stop()
self.accept()
try:
ImportURIsProgressDialog.ActiveDialogs.remove(self)
except ValueError:
pass
if self.canceled:
return
+6 -6
View File
@@ -125,7 +125,7 @@ def routingObjectFromProfile(routingProfile: dict):
}
class RoutingPreviewDialog(AppQDialog):
class RoutingPreviewDialog(AppQTransientDialog):
"""Present the routing preview dialog."""
def __init__(self, routingProfile: dict, parent=None):
@@ -168,7 +168,7 @@ class RoutingPreviewDialog(AppQDialog):
self.setFixedSize(400, int(400 * GOLDEN_RATIO))
class RoutingTextEditDialog(AppQDialog):
class RoutingTextEditDialog(AppQTransientDialog):
"""Present the routing text edit dialog."""
def __init__(self, text='', parent=None):
@@ -358,7 +358,7 @@ class RoutingProfilesModel(QtCore.QAbstractTableModel):
)
class RoutingRuleEditDialog(AppQDialog):
class RoutingRuleEditDialog(AppQTransientDialog):
"""Present the routing rule edit dialog."""
MatchInputHeight = 72
@@ -587,7 +587,7 @@ class RoutingRuleEditDialog(AppQDialog):
return rule
class RoutingRemarkEditDialog(AppQDialog):
class RoutingRemarkEditDialog(AppQTransientDialog):
"""Present the routing remark edit dialog."""
def __init__(self, remark: str, parent=None):
@@ -623,7 +623,7 @@ class RoutingRemarkEditDialog(AppQDialog):
return self.remarkEdit.text().strip()
class RoutingProfileEditDialog(AppQDialog):
class RoutingProfileEditDialog(AppQTransientDialog):
"""Present the routing profile edit dialog."""
def __init__(self, parent=None):
@@ -770,7 +770,7 @@ class RoutingRulesQListWidget(AppQListWidget):
self.contextMenu.exec(self.viewport().mapToGlobal(point))
class RoutingRulesDialog(AppQDialog):
class RoutingRulesDialog(AppQTransientDialog):
"""Present the routing rules dialog."""
def __init__(self, routing: dict, parent=None):
+37
View File
@@ -161,6 +161,24 @@ class MsgQueue(multiprocessing.queues.Queue):
"""Stop timer."""
self.timer.stop()
def dispose(self):
"""Release Qt and multiprocessing resources after final process use."""
self.stopTimer()
try:
self.timer.timeout.disconnect(self.processMsg)
except (RuntimeError, TypeError):
pass
self.timer.deleteLater()
self.callback = None
self.backgroundOptimizer = None
try:
self.close()
except (OSError, ValueError):
pass
@property
def optimizer(self):
"""Return the optimizer value."""
@@ -293,6 +311,19 @@ class CoreProcessMonitor(CoreProcess, ABC):
self.process = None
def dispose(self):
"""Release the monitor timer after this kernel leaves its owner pool."""
self.daemon.stop()
try:
self.daemon.timeout.disconnect(self.queryIsAlive)
except (RuntimeError, TypeError):
pass
self.daemon.deleteLater()
self.closeProcess()
self._exitCallback = None
class CoreProcessWorker(CoreProcessMonitor, ABC):
"""Run and monitor a proxy core in a child process."""
@@ -325,6 +356,12 @@ class CoreProcessWorker(CoreProcessMonitor, ABC):
# Reset internal process
self.closeProcess()
def dispose(self):
"""Release final queue, callback, process, and Qt timer ownership."""
self.msgQueue.dispose()
super().dispose()
def start(self, **kwargs) -> bool:
"""Start the core process worker."""
return self.startWithSpec(CoreLaunchSpec.fromProcessKwargs(**kwargs))
+1 -1
View File
@@ -372,7 +372,7 @@ class GuiEditorWidgetQGroupBox(GuiEditorWidget, AppQGroupBox):
return layout
class GuiEditorWidgetQDialog(EditorBinding, AppQDialog):
class GuiEditorWidgetQDialog(EditorBinding, AppQTransientDialog):
"""Present the GUI editor widget Qt dialog."""
def __init__(self, *args, **kwargs):
+53 -46
View File
@@ -40,6 +40,7 @@ __all__ = [
'AppQComboBox',
'AppQComboBoxSeparatorDelegate',
'AppQDialog',
'AppQTransientDialog',
'AppQDialogButtonBox',
'AppQGroupBox',
'AppQHeaderView',
@@ -186,21 +187,19 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog):
"""Initialize the AppQDialog."""
super().__init__(*args, **kwargs)
@callOnceOnly
def connect(key):
"""Connect the lifetime release signals once."""
release = functools.partial(AppQDialog._releaseOpenDialog, key)
self._lifetimeKey = id(self)
self._firstShowPending = True
self.finished.connect(release)
self.destroyed.connect(release)
# 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,
self._lifetimeKey,
)
@callOnceOnly
def firstShow():
"""Apply the first-show sizing once."""
self.setWidthAndHeight()
self._connectOnce = connect
self._firstShow = firstShow
self.finished.connect(release)
self.destroyed.connect(release)
if PLATFORM != 'Darwin':
self.setWidthAndHeight()
@@ -219,11 +218,9 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog):
def open(self):
"""Open and retain the dialog until it finishes or is destroyed."""
key = id(self)
key = self._lifetimeKey
AppQDialog._openDialogs[key] = self
self._connectOnce(key)
try:
self.show()
@@ -239,8 +236,9 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog):
"""Show and position the app Qt dialog."""
super().show()
if PLATFORM == 'Darwin':
self._firstShow()
if PLATFORM == 'Darwin' and self._firstShowPending:
self._firstShowPending = False
self.setWidthAndHeight()
moveToCenter(self)
@@ -257,6 +255,19 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog):
self.setWindowIcon(AppHue.connectedWindowIcon())
class AppQTransientDialog(AppQDialog):
"""Present a one-shot dialog that destroys its Qt object when closed."""
def __init__(self, *args, **kwargs):
"""Initialize a dialog whose accepted/rejected lifetime is transient."""
super().__init__(*args, **kwargs)
# QDialog normally hides on accept/reject. A long-lived Qt parent then
# keeps each closed dialog in its child tree indefinitely. Transient
# dialogs opt into native deletion so both Qt and Python ownership end.
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
class AppQDialogButtonBox(Mixins.QTranslatable, QDialogButtonBox):
"""Represent app Qt dialog button box."""
@@ -449,19 +460,14 @@ class AppQMainWindow(
"""Initialize the AppQMainWindow."""
super().__init__(*args, **kwargs)
@callOnceOnly
def connect(key):
"""Connect the lifetime release signal once."""
release = functools.partial(AppQMainWindow._releaseOpenWindow, key)
self.destroyed.connect(release)
self._lifetimeKey = id(self)
self._firstShowPending = True
@callOnceOnly
def firstShow():
"""Apply the first-show sizing once."""
self.setWidthAndHeight()
self._connectOnce = connect
self._firstShow = firstShow
release = functools.partial(
AppQMainWindow._releaseOpenWindow,
self._lifetimeKey,
)
self.destroyed.connect(release)
self.setWindowIcon(AppHue.currentWindowIcon())
@@ -477,11 +483,9 @@ class AppQMainWindow(
def show(self):
"""Show, position, and retain the window until it closes."""
key = id(self)
key = self._lifetimeKey
AppQMainWindow._openWindows[key] = self
self._connectOnce(key)
try:
super().show()
except Exception:
@@ -491,8 +495,9 @@ class AppQMainWindow(
raise
if PLATFORM == 'Darwin':
self._firstShow()
if PLATFORM == 'Darwin' and self._firstShowPending:
self._firstShowPending = False
self.setWidthAndHeight()
moveToCenter(self)
@@ -508,7 +513,7 @@ class AppQMainWindow(
result = super().event(event)
if closes and event.isAccepted():
AppQMainWindow._releaseOpenWindow(id(self))
AppQMainWindow._releaseOpenWindow(self._lifetimeKey)
return result
@@ -603,15 +608,19 @@ class AppQMessageBox(Mixins.QTranslatable, Mixins.ConnectionAware, QMessageBox):
"""Initialize the AppQMessageBox."""
super().__init__(*args, **kwargs)
@callOnceOnly
def connect(key):
"""Connect the lifetime release signals once."""
release = functools.partial(AppQMessageBox._releaseOpenMessageBox, key)
self._lifetimeKey = id(self)
self.finished.connect(release)
self.destroyed.connect(release)
release = functools.partial(
AppQMessageBox._releaseOpenMessageBox,
self._lifetimeKey,
)
self._connectOnce = connect
self.finished.connect(release)
self.destroyed.connect(release)
# Message boxes in Furious are one-shot notifications or questions.
# Parent ownership must not retain a hidden native box after completion.
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.setWindowIcon(AppHue.currentWindowIcon())
@@ -634,11 +643,9 @@ class AppQMessageBox(Mixins.QTranslatable, Mixins.ConnectionAware, QMessageBox):
def open(self):
"""Open and retain the message box until it finishes or is destroyed."""
key = id(self)
key = self._lifetimeKey
AppQMessageBox._openMessageBoxes[key] = self
self._connectOnce(key)
try:
self.show()
self.moveToCenter()
+38 -16
View File
@@ -28,7 +28,6 @@ from PySide6.QtNetwork import *
from typing import Union
import logging
import functools
__all__ = ['WebGETManager']
@@ -46,6 +45,7 @@ class WebGETManager(AppQNetworkAccessManager):
self.mustCallOnce = kwargs.pop('mustCallOnce', True)
self.mustCalled = False
self._replyContexts = {}
def successCallback(self, networkReply: QNetworkReply, **kwargs):
"""Handle a successful network operation."""
@@ -86,6 +86,29 @@ class WebGETManager(AppQNetworkAccessManager):
"""Handle ready read by network reply."""
self.hasDataCallback(networkReply, **kwargs)
@QtCore.Slot()
def _handleReadyRead(self):
"""Dispatch ready-read data without a closure retaining the reply."""
networkReply = self.sender()
if isinstance(networkReply, QNetworkReply):
self.handleReadyReadByNetworkReply(
networkReply,
**self._replyContexts.get(id(networkReply), {}),
)
@QtCore.Slot()
def _handleFinished(self):
"""Dispatch and release one completed network reply."""
networkReply = self.sender()
if not isinstance(networkReply, QNetworkReply):
return
kwargs = self._replyContexts.pop(id(networkReply), {})
self.handleFinishedByNetworkReply(networkReply, **kwargs)
def handleFinishedByNetworkReply(self, networkReply: QNetworkReply, **kwargs):
"""Handle finished by network reply."""
try:
@@ -113,7 +136,14 @@ class WebGETManager(AppQNetworkAccessManager):
self.successCallback(networkReply, **kwargs)
finally:
self.must(**kwargs)
try:
self.must(**kwargs)
finally:
# QNetworkAccessManager owns replies by default and does not
# remove completed children automatically. All response data
# has been consumed by this point. The shared slots above use
# sender(), so no per-request closure retains this wrapper.
networkReply.deleteLater()
def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool:
"""Configure HTTP proxy."""
@@ -133,19 +163,11 @@ class WebGETManager(AppQNetworkAccessManager):
else:
networkReply = self.get(QNetworkRequest(QtCore.QUrl(request)))
networkReply.readyRead.connect(
functools.partial(
self.handleReadyReadByNetworkReply,
networkReply,
**kwargs,
)
)
networkReply.finished.connect(
functools.partial(
self.handleFinishedByNetworkReply,
networkReply,
**kwargs,
)
)
key = id(networkReply)
self._replyContexts[key] = dict(kwargs)
networkReply.readyRead.connect(self._handleReadyRead)
networkReply.finished.connect(self._handleFinished)
return networkReply
+2
View File
@@ -55,6 +55,7 @@ from .QtWidgets import (
AppQComboBox,
AppQComboBoxSeparatorDelegate,
AppQDialog,
AppQTransientDialog,
AppQDialogButtonBox,
AppQGroupBox,
AppQHeaderView,
@@ -106,6 +107,7 @@ __all__ = [
'AppQComboBox',
'AppQComboBoxSeparatorDelegate',
'AppQDialog',
'AppQTransientDialog',
'AppQDialogButtonBox',
'AppQGroupBox',
'AppQHeaderView',
+10
View File
@@ -547,6 +547,16 @@ class ConnectionManager(Mixins.CleanupOnExit):
# Any non-exit exceptions
logger.error(f'error stopping core process: {ex}')
finally:
dispose = getattr(process, 'dispose', None)
if callable(dispose):
try:
dispose()
except Exception as ex:
# Any non-exit exceptions
logger.error(f'error disposing core process: {ex}')
finally:
self.processesPool.clear()
+33 -14
View File
@@ -26,7 +26,6 @@ from PySide6 import QtCore
from PySide6.QtNetwork import *
import logging
import functools
__all__ = ['ConnectivityManager']
@@ -47,11 +46,22 @@ class ConnectivityManager(Mixins.ConnectionAware, WebGETManager):
self.jobStatus = False
self.jobInterval = ConnectivityManager.MIN_JOB_INTERVAL
self._testingEnabled = False
self._activeReply = None
self.jobTimeoutTimer = QtCore.QTimer()
self.jobArrangeTimer = QtCore.QTimer()
self.jobTimeoutTimer = QtCore.QTimer(self)
self.jobTimeoutTimer.setSingleShot(True)
self.jobTimeoutTimer.timeout.connect(self._abortActiveReply)
self.jobArrangeTimer.timeout.connect(lambda: self.startSingleTest())
self.jobArrangeTimer = QtCore.QTimer(self)
self.jobArrangeTimer.setSingleShot(True)
self.jobArrangeTimer.timeout.connect(self.startSingleTest)
@QtCore.Slot()
def _abortActiveReply(self):
"""Abort the one currently active connectivity request."""
if isinstance(self._activeReply, QNetworkReply):
self._activeReply.abort()
def recalculateJobInterval(self, jobStatus: bool) -> int:
"""Return the recalculate job interval value used by the network connectivity manager."""
@@ -72,17 +82,30 @@ class ConnectivityManager(Mixins.ConnectionAware, WebGETManager):
def successCallback(self, networkReply, **kwargs):
"""Handle a successful network operation."""
if self._activeReply is networkReply:
self._activeReply = None
self.jobTimeoutTimer.stop()
self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=True))
if self._testingEnabled:
self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=True))
def failureCallback(self, networkReply, **kwargs):
"""Handle a failed network operation."""
if self._activeReply is networkReply:
self._activeReply = None
self.jobTimeoutTimer.stop()
self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=False))
if self._testingEnabled:
self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=False))
def startSingleTest(self):
# Use custom network connectivity test URL if possible
"""Start single test."""
if not self._testingEnabled or self._activeReply is not None:
return
settings = AppSettings.get('CustomNetworkConnectivityTestURL')
if isinstance(settings, str):
@@ -90,20 +113,15 @@ class ConnectivityManager(Mixins.ConnectionAware, WebGETManager):
else:
url = NETWORK_CONNECTIVITY_TEST_URL
networkReply = self.webGET(url)
def abort(_networkReply):
"""Cancel the active network connectivity manager operation."""
if isinstance(_networkReply, QNetworkReply):
_networkReply.abort()
self.jobTimeoutTimer.timeout.connect(functools.partial(abort, networkReply))
self._activeReply = self.webGET(url)
self.jobTimeoutTimer.start(ConnectivityManager.MIN_JOB_INTERVAL - 500)
def stopTest(self):
"""Stop test."""
self._testingEnabled = False
self.jobArrangeTimer.stop()
self.jobTimeoutTimer.stop()
self._abortActiveReply()
def connectedCallback(self):
"""Update the network connectivity manager for a connected state."""
@@ -114,6 +132,7 @@ class ConnectivityManager(Mixins.ConnectionAware, WebGETManager):
self.stopTest()
else:
self.jobInterval = ConnectivityManager.MIN_JOB_INTERVAL
self._testingEnabled = True
self.jobArrangeTimer.start(self.jobInterval)
+14 -13
View File
@@ -38,22 +38,23 @@ class ConnectionProgressBar(Mixins.ConnectionAware, QProgressBar):
self.setRange(0, 100)
@QtCore.Slot()
def update():
# Update the progress bar value
"""Update the auto update progress bar."""
if self.value() < 90:
self.setValue(self.value() + 1)
# Stop the timer when the progress bar reaches 100%
if self.value() > 99:
self.timer.stop()
self.timer = QtCore.QTimer()
self.timer.timeout.connect(update)
# The timer belongs to the progress bar. Parenting it and connecting
# to a normal method avoids a parentless timer/closure cycle surviving
# after the widget's Qt lifetime ends.
self.timer = QtCore.QTimer(self)
self.timer.timeout.connect(self._advance)
self._setConnectionState('disconnected')
@QtCore.Slot()
def _advance(self):
"""Advance the connection progress animation."""
if self.value() < 90:
self.setValue(self.value() + 1)
if self.value() > 99:
self.timer.stop()
def _setConnectionState(self, state: str):
"""Expose semantic state to the application-owned progress style."""
if self.property('connectionState') == state:
+9 -3
View File
@@ -524,11 +524,11 @@ class TestDownloadSpeedWorker(WebGETManager):
self.networkReply = None
self.elapsedTimer = QtCore.QElapsedTimer()
self.timeoutTimer = QtCore.QTimer()
self.timeoutTimer = QtCore.QTimer(self)
self.timeoutTimer.setSingleShot(True)
self.timeoutTimer.timeout.connect(self.handleTimeout)
def mustCall(self):
def mustCall(self, **kwargs):
"""Perform the required completion hook."""
self.timeoutTimer.stop()
self.finished.emit(self)
@@ -880,10 +880,16 @@ class DownloadSpeedTestScheduler(QtCore.QObject):
return
self.releasePort(port)
# Completed workers are children of the long-lived scheduler. Merely
# removing the Python dictionary entry would leave every worker (and
# its network/timer children) in the scheduler's QObject tree.
worker.deleteLater()
self.scheduleDrain()
class DeleteServersProgressDialog(AppQDialog):
class DeleteServersProgressDialog(AppQTransientDialog):
"""Present progress and cancellation controls for delete servers."""
def __init__(self, table, indexes, showTrayMessage=True, parent=None):
+4
View File
@@ -648,6 +648,10 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
self.timers.pop(deleteIndex)
self.timerConnected.pop(deleteIndex)
# The table is the timer's Qt parent, so dropping the list
# reference alone would retain it until application exit.
qtimer.deleteLater()
self.sourceModel.endRemoveRows()
if callable(self.deleteUniqueCallback):
+6 -9
View File
@@ -436,6 +436,9 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
importActionsFactory=self.serverImportActions,
)
pluginRegistry = getPluginRegistry()
# These two settings dialogs intentionally remain parent-owned and are
# refreshed/reused by SettingsPage instead of being recreated.
self.customizeProxyBypassDialog = ProxyBypassDialog(parent=self)
self.customizeNetworkTestDialog = NetworkTestDialog(parent=self)
@@ -669,16 +672,10 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
self.userServersQTableWidget.hideTabAndSpaces()
def getGuiTUNSettings(self, **kwargs):
"""Return GUI TUN settings."""
"""Create a transient TUN settings editor for the settings page."""
parent = kwargs.pop('parent', self)
@functools.lru_cache(None)
def cachedGuiTUNSettings():
"""Return the TUN settings editor owned by the home page."""
parent = kwargs.pop('parent', self)
return TunSettingsDialog(parent=parent, **kwargs)
guiTUNSettings = cachedGuiTUNSettings()
guiTUNSettings = TunSettingsDialog(parent=parent, **kwargs)
guiTUNSettings.factoryToInput(Storage.UserTUNSettings())
return guiTUNSettings
+1 -1
View File
@@ -28,7 +28,7 @@ from PySide6.QtWidgets import *
__all__ = ['IndentDialog']
class IndentDialog(AppQDialog):
class IndentDialog(AppQTransientDialog):
"""Represent indent spin box."""
def __init__(self, parent=None):
+1 -1
View File
@@ -54,7 +54,7 @@ def _validSubscriptionURL(value) -> str:
return url
class _SubscriptionEditorDialog(AppQDialog):
class _SubscriptionEditorDialog(AppQTransientDialog):
"""Edit one complete subscription definition with validation."""
def __init__(self, subscription=None, parent=None):
+8 -1
View File
@@ -400,6 +400,10 @@ class TextEditorWindow(AppQMainWindow):
def closeEvent(self, event: QtCore.QEvent):
"""Handle closure of the text editor window."""
if self.modified:
# A close event arrives accepted by default. Keep the reusable
# editor alive unless the user explicitly saves or discards.
event.ignore()
mbox = MBoxQuestionSave(icon=AppQMessageBox.Icon.Question, parent=self)
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
@@ -428,8 +432,11 @@ class TextEditorWindow(AppQMainWindow):
# Show the MessageBox and wait for the user to close it
mbox.exec()
if event.isAccepted():
super().closeEvent(event)
else:
event.accept()
super().closeEvent(event)
def retranslate(self):
# Do nothing