From 159e767bf8ef40a85ff8ce9e6be8f6d27ed1124f Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Wed, 19 Aug 2026 21:59:21 +0800 Subject: [PATCH] Simplify application lifecycle cleanup Signed-off-by: Loren Eteval --- Furious/Application/DesktopApplication.py | 518 ++++++++++++++-------- 1 file changed, 326 insertions(+), 192 deletions(-) diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 3813749..173cc96 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -19,8 +19,33 @@ from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * +from Furious.Frozenlib import ( + APPLICATION_FLATPAK_ID, + APPLICATION_NAME, + APPLICATION_VERSION, + DATA_DIR, + LOCAL_SERVER_NAME, + ORGANIZATION_DOMAIN, + ORGANIZATION_NAME, + OS_CPU_COUNT, + PLATFORM, + PLATFORM_MACHINE, + PLATFORM_PYTHON_VERSION, + PLATFORM_RELEASE, + PYSIDE6_VERSION, + SYSTEM_LANGUAGE, + AppBuiltinCommand, + AppBuiltinProxyMode, + AppConnectionController, + AppSettings, + ApplicationTheme, + Mixins, + SystemProxy, + SystemRuntime, + Win32Session, + callRateLimited, +) +from Furious.Interface import ApplicationRunner from Furious.Core import Tun2socks from Furious.Backends import OFFICIAL_PLUGIN_TYPES from Furious.Controllers import ( @@ -31,32 +56,65 @@ from Furious.Controllers import ( SettingsController, ) from Furious.Extensions import BUNDLED_EXTENSION_TYPES -from Furious.Plugins import getPluginRegistry, initializePluginRegistry +from Furious.Plugins import initializePluginRegistry from Furious.Qt import AppStyleSheet from Furious.Qt.TextEditorTheme import configureEditorLogMetadata from Furious.Qt import gettext as _ -from Furious.Repository import * +from Furious.Repository import Storage from Furious.Service import ApplicationLogHandler, LogManager -from Furious.Application.TrayIcon import * -from Furious.Window.LogPage import * -from Furious.Window.MainWindow import * +from Furious.Application.TrayIcon import TrayIcon +from Furious.Window.LogPage import LogPage +from Furious.Window.MainWindow import MainWindow from PySide6 import QtCore -from PySide6.QtGui import * -from PySide6.QtNetwork import * -from PySide6.QtWidgets import * +from PySide6.QtGui import QFontDatabase, QPalette +from PySide6.QtNetwork import QLocalServer, QLocalSocket +from PySide6.QtWidgets import QApplication import os import sys import logging import platform -import threading import traceback import darkdetect logger = logging.getLogger(__name__) +class _ApplicationCleanupStack: + """Run acquired application-resource cleanup in reverse order exactly once.""" + + def __init__(self): + self._callbacks = [] + self._closed = False + + def register(self, stage: str, callback): + """Record cleanup after *stage* has completed successfully.""" + if self._closed: + raise RuntimeError('application cleanup has already completed') + + self._callbacks.append((stage, callback)) + + def close(self): + """Release every acquired stage in reverse order, continuing on errors.""" + if self._closed: + return False + + self._closed = True + + while self._callbacks: + stage, callback = self._callbacks.pop() + + try: + callback() + except Exception: + # Any non-exit exceptions + + logger.exception(f'application cleanup failed for stage {stage}') + + return True + + class SystemTrayUnavailable(Exception): """Represent system tray unavailable.""" @@ -148,19 +206,12 @@ class SingletonApplication(ApplicationExitHelper): raise NotImplementedError -class ApplicationThemeDetector(QtCore.QObject): - """Represent application theme detector.""" - - themeChanged = QtCore.Signal(str) - - def __init__(self, *args, **kwargs): - """Initialize the ApplicationThemeDetector.""" - super().__init__(*args, **kwargs) - - class DesktopApplication(ApplicationRunner, SingletonApplication): """Represent application.""" + _sessionShutdownRequested = QtCore.Signal() + ThreadPoolShutdownTimeout = 5000 + def __init__(self, argv): """Initialize the desktop application.""" super().__init__(argv) @@ -180,8 +231,6 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): # Theme Detect self.currentTheme = None self.themeDetectTimer = None - self.themeDetector = None - self.themeListenerThread = None self.mainWindow = None self.systemTray = None @@ -203,6 +252,19 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.threadPool = QtCore.QThreadPool(self) self.threadPool.setMaxThreadCount(max(OS_CPU_COUNT // 2, 1)) + self._cleanupStack = _ApplicationCleanupStack() + self._cleanupStack.register('thread pool', self._cleanupThreadPool) + + self._exitRequested = False + self._exitCode = ApplicationRunner.ExitCode.ExitSuccess.value + + # win32session invokes its callback on the native listener thread. + # Queue the request so every Qt-owned cleanup stage stays on the GUI thread. + self._sessionShutdownRequested.connect( + self.exit, + QtCore.Qt.ConnectionType.QueuedConnection, + ) + @callRateLimited(maxCallPerSecond=2) @QtCore.Slot() def handleNewConnection(self): @@ -307,17 +369,27 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): pluginRegistry = initializePluginRegistry( (*OFFICIAL_PLUGIN_TYPES, *BUNDLED_EXTENSION_TYPES) ) - configureEditorLogMetadata( - lambda: (*pluginRegistry.coreVersions(), Tun2socks.version()), - pluginRegistry.logTimestampPatterns, - ) - pluginRegistry.configureEnvironment() - if SystemRuntime.flatpakID(): - # https://github.com/flatpak/flatpak/issues/3438 - os.environ['TMPDIR'] = os.path.join( - os.environ.get('XDG_RUNTIME_DIR'), 'app', APPLICATION_FLATPAK_ID + try: + configureEditorLogMetadata( + lambda: (*pluginRegistry.coreVersions(), Tun2socks.version()), + pluginRegistry.logTimestampPatterns, ) + pluginRegistry.configureEnvironment() + + if SystemRuntime.flatpakID(): + # https://github.com/flatpak/flatpak/issues/3438 + os.environ['TMPDIR'] = os.path.join( + os.environ.get('XDG_RUNTIME_DIR'), 'app', APPLICATION_FLATPAK_ID + ) + except Exception: + # Any non-exit exceptions + + pluginRegistry.shutdown() + + raise + + return pluginRegistry def addStorage(self): # Protected storage access @@ -327,6 +399,192 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self._userSubs = Storage.UserSubs() self._userTUNSettings = Storage.UserTUNSettings() + def _initializeControllers(self): + """Create the process-lifetime application state authorities.""" + try: + ( + self.connectionController, + self.routingController, + self.settingsController, + ) = ( + ConnectionController(parent=self), + RoutingController(parent=self), + SettingsController(), + ) + + self.connectionController.interactionEnabledChanged.connect( + self.routingController.setInteractionEnabled + ) + except Exception: + # Any non-exit exceptions + + self._cleanupControllers() + + raise + + def _cleanupControllers(self): + """Shut down and release each controller acquired during startup.""" + if self.connectionController is not None: + try: + self.connectionController.shutdown() + except Exception: + # Any non-exit exceptions + + logger.exception('connection controller shutdown failed') + + for controllerName in ('routingController', 'connectionController'): + controller = getattr(self, controllerName) + + if isinstance(controller, QtCore.QObject): + controller.deleteLater() + + setattr(self, controllerName, None) + + self.settingsController = None + + def _initializeThemeDetection(self): + """Start the one application-owned theme observer.""" + logger.info('theme detect method uses timer implementation') + + @QtCore.Slot() + def handleTimeout(): + """Apply a changed system theme.""" + currentTheme = self.systemTheme() + + if self.currentTheme != currentTheme: + self.currentTheme = currentTheme + self.handleSystemThemeChanged(currentTheme) + + self.currentTheme = self.systemTheme() + self.themeDetectTimer = QtCore.QTimer(self) + self.themeDetectTimer.timeout.connect(handleTimeout) + self.themeDetectTimer.start(1000) + + def _stopThemeDetection(self): + """Stop Qt-owned theme polling during rollback or final cleanup.""" + if isinstance(self.themeDetectTimer, QtCore.QTimer): + self.themeDetectTimer.stop() + + def _cleanupThreadPool(self): + """Cancel queued work and boundedly finish already-running Qt work.""" + self.threadPool.clear() + + if not self.threadPool.waitForDone(self.ThreadPoolShutdownTimeout): + logger.error('application thread pool did not stop before timeout') + + def _initializeSystemIntegration(self): + """Install operating-system session and automatic proxy integration.""" + self.setQuitOnLastWindowClosed(False) + + if Win32Session.set(self._sessionShutdownRequested.emit): + self._cleanupStack.register('Windows session listener', Win32Session.off) + + if not Win32Session.run(): + logger.error('failed to start Windows session listener') + + if AppSettings.get('SystemProxyMode') == AppBuiltinProxyMode.Auto.value: + SystemProxy.off() + SystemProxy.daemonOn_() + + self._cleanupStack.register( + 'automatic system proxy', self._cleanupSystemProxy + ) + + @staticmethod + def _cleanupSystemProxy(): + """Release the exact automatic system-proxy integration.""" + SystemProxy.off() + SystemProxy.daemonOff() + + def _initializeUI(self): + """Create and bootstrap the application-owned main window and tray.""" + try: + self.applyThemePreference() + self.mainWindow = MainWindow() + self.systemTray = TrayIcon(parent=self) + + if PLATFORM == 'Darwin': + if AppSettings.isStateON_('HideDockIcon'): + self.installDockIconVisibilityFeature() + + def onApplicationStateChange(state): + """Show the main window when the macOS dock activates the app.""" + if QtCore.Qt.ApplicationState(state) != ( + QtCore.Qt.ApplicationState.ApplicationActive + ): + return + + controller = AppConnectionController() + + if ( + not self.mainWindow.isVisible() + and controller is not None + and not controller.isConnecting() + ): + self.mainWindow.show() + + self.applicationStateChanged.connect(onApplicationStateChange) + + self.systemTray.show() + self.systemTray.setCustomToolTip() + self.systemTray.bootstrap() + except Exception: + # Any non-exit exceptions + + self._cleanupUI() + + raise + + def _cleanupUI(self): + """Hide and release application-owned top-level UI objects.""" + if self.systemTray is not None: + self.systemTray.hide() + self.systemTray.deleteLater() + self.systemTray = None + + if self.mainWindow is not None: + self.mainWindow.hide() + self.mainWindow.deleteLater() + self.mainWindow = None + + def _logRuntimeInformation(self): + """Log the initialized runtime environment without acquiring resources.""" + logger.info(f'application version: {APPLICATION_VERSION}') + logger.info( + f'Qt version: {QtCore.qVersion()}. PySide6 version: {PYSIDE6_VERSION}' + ) + logger.info(f'Qt build info: {QtCore.QLibraryInfo.build()}') + logger.info(f'platform: {PLATFORM}') + logger.info(f'platform release: {PLATFORM_RELEASE}') + logger.info(f'platform machine: {PLATFORM_MACHINE}') + + if PLATFORM == 'Darwin': + logger.info(f'mac_ver: {platform.mac_ver()}') + + appImagePath = SystemRuntime.appImagePath() + + if appImagePath: + logger.info(f'running from Linux AppImage: {appImagePath}') + else: + logger.info(f'not running from Linux AppImage') + + flatpakId = SystemRuntime.flatpakID() + + if flatpakId: + logger.info(f'running from Linux flatpak: {flatpakId}') + else: + logger.info(f'not running from Linux flatpak') + + logger.info(f'python version: {PLATFORM_PYTHON_VERSION}') + logger.info(f'system version: {sys.version}') + logger.info(f'sys.executable: {sys.executable}') + logger.info(f'sys.argv: {sys.argv}') + logger.info(f'appFilePath: {self.applicationFilePath()}') + logger.info(f'isPythonw: {SystemRuntime.isPythonw()}') + logger.info(f'system language is {SYSTEM_LANGUAGE}') + logger.info(self.customFontLoadMsg) + logger.info(f'current theme is {self.theme()}') + def isSystemDarkMode(self): """Return whether the current system palette appears dark.""" backgroudColor = self.palette().color(QPalette.ColorRole.Window) @@ -398,28 +656,11 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): Mixins.ThemeAware.callThemeChangedCallback(theme) - @staticmethod - @callOnceOnly @QtCore.Slot() - def cleanup(): - """Release resources owned by the application.""" - controller = AppConnectionController() - - if controller is not None: - controller.shutdown() - - getPluginRegistry().shutdown() - - Mixins.CleanupOnExit.cleanupAll() - - if AppSettings.get('SystemProxyMode') == AppBuiltinProxyMode.Auto.value: - # Automatically configure - SystemProxy.off() - SystemProxy.daemonOff() - - AppThreadPool().clear() - - logger.info('final cleanup done') + def cleanup(self): + """Release every successfully acquired application stage exactly once.""" + if self._cleanupStack.close(): + logger.info('final cleanup done') @staticmethod def setDockIconVisible(visible: bool): @@ -459,16 +700,15 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.setDockIconVisible(False) def exit(self, exitcode=0): - """Shut down and exit the application.""" + """Request normal event-loop termination; ``aboutToQuit`` owns cleanup.""" + if self._exitRequested: + return + + self._exitRequested = True + self._exitCode = int(exitcode) self.setExitingFlag(True) - self.threadPool.clear() - - # Dirty exit - for timeout in [1000, 2000, 3000]: - QtCore.QTimer.singleShot(timeout, APP().exit) - - super().exit(exitcode) + QtCore.QCoreApplication.exit(self._exitCode) def run(self): """Run the application task.""" @@ -485,148 +725,40 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): 'TrayIcon is not available on this platform' ) - self.addEnviron() - self.addStorage() + pluginRegistry = self.addEnviron() - # The application owns these lifetime-scoped services; Frozenlib - # exposes them through App*Controller accessors. - ( - self.connectionController, - self.routingController, - self.settingsController, - ) = ( - ConnectionController(parent=self), - RoutingController(parent=self), - SettingsController(), - ) + self._cleanupStack.register('plugins', pluginRegistry.shutdown) - self.connectionController.interactionEnabledChanged.connect( - self.routingController.setInteractionEnabled + try: + self.addStorage() + except Exception: + Mixins.CleanupOnExit.cleanupAll() + + raise + + self._cleanupStack.register( + 'mixin-owned resources', Mixins.CleanupOnExit.cleanupAll ) + self._initializeControllers() + self._cleanupStack.register('controllers', self._cleanupControllers) self.addCustomFont() # self.configureApplicationFont() self.configureLogging() + self._logRuntimeInformation() - logger.info(f'application version: {APPLICATION_VERSION}') - logger.info( - f'Qt version: {QtCore.qVersion()}. PySide6 version: {PYSIDE6_VERSION}' - ) - logger.info(f'Qt build info: {QtCore.QLibraryInfo.build()}') - logger.info(f'platform: {PLATFORM}') - logger.info(f'platform release: {PLATFORM_RELEASE}') - logger.info(f'platform machine: {PLATFORM_MACHINE}') + self._initializeThemeDetection() + self._cleanupStack.register('theme detection', self._stopThemeDetection) - if PLATFORM == 'Darwin': - logger.info(f'mac_ver: {platform.mac_ver()}') + self.aboutToQuit.connect(self.cleanup) + self._initializeSystemIntegration() - appImagePath = SystemRuntime.appImagePath() + self._initializeUI() + self._cleanupStack.register('application UI', self._cleanupUI) + self.connectionController.restoreStartupState() - if appImagePath: - logger.info(f'running from Linux AppImage: \'{appImagePath}\'') - else: - logger.info('not running from Linux AppImage') - - flatpakId = SystemRuntime.flatpakID() - - if flatpakId: - logger.info(f'running from Linux flatpak: \'{flatpakId}\'') - else: - logger.info('not running from Linux flatpak') - - logger.info(f'python version: {PLATFORM_PYTHON_VERSION}') - logger.info(f'system version: {sys.version}') - logger.info(f'sys.executable: \'{sys.executable}\'') - logger.info(f'sys.argv: {sys.argv}') - logger.info(f'appFilePath: \'{self.applicationFilePath()}\'') - logger.info(f'isPythonw: {SystemRuntime.isPythonw()}') - logger.info(f'system language is {SYSTEM_LANGUAGE}') - logger.info(self.customFontLoadMsg) - logger.info(f'current theme is {self.theme()}') - - if PLATFORM != 'Windows' and not SystemRuntime.isScriptMode(): - logger.info('theme detect method uses timer implementation') - - @QtCore.Slot() - def handleTimeout(): - """Handle timeout.""" - currentTheme = self.systemTheme() - - if self.currentTheme != currentTheme: - self.currentTheme = currentTheme - - self.handleSystemThemeChanged(currentTheme) - - self.currentTheme = self.systemTheme() - self.themeDetectTimer = QtCore.QTimer(self) - self.themeDetectTimer.timeout.connect(handleTimeout) - self.themeDetectTimer.start(1000) - else: - logger.info('theme detect method uses listener implementation') - - def listener(*args, **kwargs): - """Handle listener for the application.""" - try: - darkdetect.listener(*args, **kwargs) - except NotImplementedError: - # Not supported by darkdetect. Ignore - - logger.error( - 'darkdetect listener is not implemented on this platform' - ) - - self.themeDetector = ApplicationThemeDetector(self) - self.themeDetector.themeChanged.connect(self.handleSystemThemeChanged) - - self.themeListenerThread = threading.Thread( - target=listener, - args=(self.themeDetector.themeChanged.emit,), - daemon=True, - ) - self.themeListenerThread.start() - - # Mandatory - self.setQuitOnLastWindowClosed(False) - - self.aboutToQuit.connect(DesktopApplication.cleanup) - - Win32Session.set(DesktopApplication.cleanup) - Win32Session.run() - - if AppSettings.get('SystemProxyMode') == AppBuiltinProxyMode.Auto.value: - # Automatically configure - SystemProxy.off() - SystemProxy.daemonOn_() - - # Resolve the stored preference before constructing application UI so - # newly created widgets use the correct palette from their first frame. - self.applyThemePreference() - - self.mainWindow = MainWindow() - self.systemTray = TrayIcon() - - if PLATFORM == 'Darwin': - if AppSettings.isStateON_('HideDockIcon'): - # Hide Dock icon initially, keeping only the tray icon visible - self.installDockIconVisibilityFeature() - - def onApplicationStateChange(state): - """Handle on application state change for the application.""" - if state == QtCore.Qt.ApplicationState.ApplicationActive: - if ( - not self.mainWindow.isVisible() - and not AppConnectionController().isConnecting() - ): - self.mainWindow.show() - - # Ensure the main window is shown when the dock icon is clicked - self.applicationStateChanged.connect(onApplicationStateChange) - - self.systemTray.show() - self.systemTray.setCustomToolTip() - self.systemTray.bootstrap() - - AppConnectionController().restoreStartupState() + if self._exitRequested: + return self._exitCode return self.exec() except SystemTrayUnavailable: @@ -637,3 +769,5 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): traceback.print_exc() return ApplicationRunner.ExitCode.UnknownException.value + finally: + self.cleanup()