From dfcdfd254f1693d0faa5a6b234e4af630c102d25 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sat, 10 Oct 2026 20:34:30 +0800 Subject: [PATCH] Keep disconnect and quit responsive Signed-off-by: Loren Eteval --- Furious/Application/DesktopApplication.py | 55 ++++- Furious/Controllers/ConnectionController.py | 127 ++++++++++- tests/test_architecture_refactors.py | 151 ++++++++++++- tests/test_sing_tun.py | 236 +++++++++++++++++++- 4 files changed, 558 insertions(+), 11 deletions(-) diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 0244b28a..a5da1e99 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -58,7 +58,13 @@ from Furious.Controllers import ( ) from Furious.Extensions import BUNDLED_EXTENSION_TYPES from Furious.Plugins import initializePluginRegistry -from Furious.Qt import AppQMessageBox, AppStyleSheet, ThemeTransition, connectWeakly +from Furious.Qt import ( + AppQMessageBox, + AppStyleSheet, + ThemeTransition, + connectWeakly, + singleShotWeakly, +) from Furious.Qt.TextEditorTheme import configureEditorLogMetadata from Furious.Qt import gettext as _ from Furious.Repository import Storage @@ -452,6 +458,8 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self._cleanupStack.register('thread pool', self._cleanupThreadPool) self._exitRequested = False + self._exitCommitted = False + self._exitConnection = None self._exitCode = ApplicationRunner.ExitCode.ExitSuccess.value # win32session invokes its callback on the native listener thread. @@ -655,7 +663,9 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): if self.connectionController is not None: try: - self.connectionController.shutdown() + if self.connectionController.shutdown() is False: + connectionReleased = False + logger.error('connection controller cleanup is still pending') except Exception: # Any non-exit exceptions @@ -749,6 +759,12 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): ): self.exit() + if not self._exitCommitted: + # Keep the window usable if authorization is pending or the + # eventual cleanup result prevents application exit. + event.ignore() + return True + # Preserve normal close-event delivery so AppQMainWindow can # release its shown-window lifetime entry. return False @@ -1001,7 +1017,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.setDockIconVisible(False) def exit(self, exitcode=0): - """Request normal event-loop termination; ``aboutToQuit`` owns cleanup.""" + """Finish connection cleanup before requesting event-loop termination.""" if self._exitRequested: return @@ -1009,6 +1025,37 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self._exitCode = int(exitcode) self.setExitingFlag(True) + if self.connectionController is not None: + if self._exitConnection is None: + self._exitConnection = connectWeakly( + self.connectionController.shutdownFinished, + self, + '_connectionShutdownFinished', + sender=self.connectionController, + ) + try: + self.connectionController.shutdown() + except Exception as ex: + # Any non-exit exceptions + logger.error(f'connection shutdown prevented application exit: {ex}') + self._connectionShutdownFinished(False) + return + + self._completeExit() + + def _connectionShutdownFinished(self, success): + if not self._exitRequested or self._exitCommitted: + return + if success: + singleShotWeakly(0, self, '_completeExit') + else: + self._exitRequested = False + self.setExitingFlag(False) + + def _completeExit(self): + if not self._exitRequested or self._exitCommitted: + return + self._exitCommitted = True QtCore.QCoreApplication.exit(self._exitCode) def run(self): @@ -1059,7 +1106,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.connectionController.restoreStartupState() - if self._exitRequested: + if self._exitCommitted: return self._exitCode return self.exec() diff --git a/Furious/Controllers/ConnectionController.py b/Furious/Controllers/ConnectionController.py index d8203d91..42f6e555 100644 --- a/Furious/Controllers/ConnectionController.py +++ b/Furious/Controllers/ConnectionController.py @@ -96,6 +96,7 @@ class ConnectionController(QtCore.QObject): progressFinished = QtCore.Signal(bool) notificationRequested = QtCore.Signal(str) errorOccurred = QtCore.Signal(object) + shutdownFinished = QtCore.Signal(bool) def __init__(self, parent=None, *, coreManager=None, updatesManager=None): """Initialize reusable runtime services and a disconnected state.""" @@ -114,6 +115,12 @@ class ConnectionController(QtCore.QObject): self._startAdmissionPending = False self._shuttingDown = False self._runtimeCleanupFailed = False + self._stopOperation = None + self._disconnectionGeneration = 0 + self._disconnectionNotification = '' + self._reconnectAfterStop = False + self._shutdownConnectPreference = None + self._shutdownComplete = False self._actionTimer = QtCore.QTimer(self) self._actionTimer.timeout.connect(self._callActionFromQueue) @@ -175,7 +182,11 @@ class ConnectionController(QtCore.QObject): self.stateChanged.emit(state) - if generation != self._connectionGeneration or self._state is not state: + if ( + not isValid(self) + or generation != self._connectionGeneration + or self._state is not state + ): return if self.interactionEnabled != interactionWasEnabled: @@ -213,7 +224,11 @@ class ConnectionController(QtCore.QObject): self._pendingHttpProxy = '' self.progressFinished.emit(True) + if not isValid(self): + return self._setActiveProfile(None) + if not isValid(self): + return AppSettings.turnOFF('Connect') @@ -571,9 +586,16 @@ class ConnectionController(QtCore.QObject): def startDisconnection(self, notification: str = '') -> bool: """Stop the active runtime and optionally request a notification.""" - if self.isDisconnecting() or ( + if self.isDisconnecting(): + self._reconnectAfterStop = False + return False + if ( self.state is ConnectionState.Disconnected and not self._startAdmissionPending + and not ( + self._shuttingDown + and callable(getattr(self._coreManager, 'stopAsync', None)) + ) ): return False @@ -588,6 +610,8 @@ class ConnectionController(QtCore.QObject): self._startOperation = None self._setState(ConnectionState.Disconnecting) + if not isValid(self) or generation != self._connectionGeneration: + return True self._actionTimer.stop() if operation is not None and isValid(operation): @@ -609,6 +633,33 @@ class ConnectionController(QtCore.QObject): logger.error(f'failed to turn off system proxy: {ex}') self._runtimeCleanupFailed = False + self._disconnectionGeneration = generation + self._disconnectionNotification = notification + + asyncStop = getattr(self._coreManager, 'stopAsync', None) + if callable(asyncStop): + try: + self._stopOperation = stopOperation = asyncStop() + connectWeakly( + stopOperation.finished, + self, + '_connectionStopFinished', + sender=stopOperation, + ) + connectWeakly( + stopOperation.destroyed, + self, + '_connectionStopDestroyed', + sender=stopOperation, + ) + stopOperation.start() + return True + except Exception as ex: + # Any non-exit exceptions + self._runtimeCleanupFailed = True + logger.error(f'failed to stop connection runtime: {ex}') + self._finishDisconnection(generation, notification) + return True try: self._coreManager.stopAll() @@ -621,10 +672,36 @@ class ConnectionController(QtCore.QObject): logger.error(f'failed to stop connection runtime: {ex}') + self._finishDisconnection(generation, notification) + return True + + def _connectionStopFinished(self, operation, error): + if operation is not self._stopOperation: + return + self._stopOperation = None + if error: + self._runtimeCleanupFailed = True + logger.error(f'failed to stop connection runtime: {error}') + self._finishDisconnection( + self._disconnectionGeneration, self._disconnectionNotification + ) + + def _connectionStopDestroyed(self, *_args): + if self._stopOperation is not None and not isValid(self._stopOperation): + self._stopOperation = None + self._runtimeCleanupFailed = True + self._finishDisconnection( + self._disconnectionGeneration, self._disconnectionNotification + ) + + def _finishDisconnection(self, generation, notification): + reconnect, self._reconnectAfterStop = self._reconnectAfterStop, False self._emitRuntimesChanged() + if not isValid(self): + return self._reset() - if generation != self._connectionGeneration: + if not isValid(self) or generation != self._connectionGeneration: return True while not self._actionQueue.empty(): @@ -636,9 +713,29 @@ class ConnectionController(QtCore.QObject): pass Mixins.ConnectionAware.callDisconnectedCallback() + if not isValid(self) or generation != self._connectionGeneration: + return if notification: self.notificationRequested.emit(notification) + if not isValid(self): + return + + if self._shuttingDown: + if self._shutdownConnectPreference: + AppSettings.turnON_('Connect') + success = not self._runtimeCleanupFailed + self._shutdownComplete = success + if not success: + self._shuttingDown = False + self._shutdownConnectPreference = None + self.shutdownFinished.emit(success) + elif ( + reconnect + and not self._runtimeCleanupFailed + and generation == self._connectionGeneration + ): + self.startConnection() return True @@ -648,7 +745,8 @@ class ConnectionController(QtCore.QObject): return False if self.isConnected() or self.isConnecting(): - self.startDisconnection(notification) + self._reconnectAfterStop = True + return self.startDisconnection(notification) return self.startConnection() @@ -661,7 +759,13 @@ class ConnectionController(QtCore.QObject): def shutdown(self): """Stop runtime resources without changing the next-start preference.""" + if self._shutdownComplete: + self.shutdownFinished.emit(True) + return True reconnectOnStartup = AppSettings.isStateON_('Connect') + if self._shutdownConnectPreference is None: + self._shutdownConnectPreference = reconnectOnStartup + reconnectOnStartup = self._shutdownConnectPreference self._shuttingDown = True @@ -669,16 +773,29 @@ class ConnectionController(QtCore.QObject): if ( self.state is not ConnectionState.Disconnected or self._startAdmissionPending + or callable(getattr(self._coreManager, 'stopAsync', None)) ): self.startDisconnection() + if self.isDisconnecting() or not self._shuttingDown: + return False + if not self._runtimeCleanupFailed: - return + return True # Disconnect keeps the UI usable on failure. Final shutdown must # surface retained resources before its owner is destroyed. self._coreManager.stopAll() self._runtimeCleanupFailed = False + self._shutdownComplete = True + self.shutdownFinished.emit(True) + return True + except Exception: + # Any non-exit exceptions + self._shuttingDown = False + self._shutdownConnectPreference = None + self.shutdownFinished.emit(False) + raise finally: if reconnectOnStartup: AppSettings.turnON_('Connect') diff --git a/tests/test_architecture_refactors.py b/tests/test_architecture_refactors.py index 6bfb656b..bf4d2a61 100644 --- a/tests/test_architecture_refactors.py +++ b/tests/test_architecture_refactors.py @@ -33,6 +33,7 @@ from Furious.Service.RuntimeLease import RuntimeEventRouter, RuntimeLease from PySide6 import QtCore from PySide6.QtNetwork import QLocalServer +from PySide6.QtWidgets import QWidget from tests.support import isolatedSettings, processQtEvents, application @@ -40,6 +41,7 @@ from types import SimpleNamespace from unittest import TestCase, mock import importlib +import functools import os import sys import tempfile @@ -126,6 +128,7 @@ class ApplicationLifecycleTransactionTest(TestCase): ), exec=stage('event loop'), _exitRequested=False, + _exitCommitted=False, _exitCode=ApplicationRunner.ExitCode.ExitSuccess.value, ) @@ -250,13 +253,107 @@ class ApplicationLifecycleTransactionTest(TestCase): self.assertFalse(stack.close()) self.assertEqual(calls, ['third', 'second', 'first']) + def testQuitKeepsTheEventLoopAliveUntilHostCleanupCompletes(self): + """Exit must allow Qt delivery while an authorization worker is pending.""" + script = textwrap.dedent(""" + import sys + import threading + from types import SimpleNamespace + from unittest import mock + from PySide6 import QtCore + from tests.support import isolatedSettings, _initializeSettingsSandbox + _initializeSettingsSandbox() + from Furious.Application import DesktopApplication + from Furious.Controllers import ConnectionController, ConnectionState + from Furious.Core.SingTUN import SingTUN + from Furious.Frozenlib import AppSettings + from Furious.Service.ConnectionManager import ConnectionManager + from Furious.Service.RuntimeLease import RuntimeLease, RuntimeEventRouter + from Furious.Service.SingTUNHost import SingTUNHostPlan + + gate, entered = threading.Event(), threading.Event() + ticks = [] + + class ProbeApplication(DesktopApplication): + def shouldExitForExistingInstance(self): + return False + def addEnviron(self): + return SimpleNamespace(shutdown=lambda: None) + def addStorage(self): + pass + def addCustomFont(self): + pass + def configureLogging(self): + pass + def _logRuntimeInformation(self): + pass + def _initializeThemeDetection(self): + pass + def _initializeSystemIntegration(self): + self.setQuitOnLastWindowClosed(False) + def _initializeUI(self): + pass + def _initializeControllers(self): + self.plan = SingTUNHostPlan({}, platform='Linux') + plan = self.plan + def cleanup(_cleanExit): + entered.set() + assert gate.wait(5) + plan._cleanupComplete = True + self.plan.cleanup = cleanup + manager = ConnectionManager() + self.runtime = SingTUN({}, hostPlan=self.plan) + router = RuntimeEventRouter() + router.attach(self.runtime, manager) + manager._leases.append(RuntimeLease(self.runtime, router)) + self.connectionController = ConnectionController( + parent=self, coreManager=manager, updatesManager=mock.Mock() + ) + self.connectionController._state = ConnectionState.Connected + + app = ProbeApplication(sys.argv) + with isolatedSettings(), mock.patch('Furious.Controllers.ConnectionController.SystemProxy.off'): + def tick(): + assert app.connectionController.state is ConnectionState.Disconnecting + assert not app._exitCommitted + ticks.append(True) + gate.set() + def requestQuit(): + AppSettings.turnON_('Connect') + app.exit(23) + assert not app._exitCommitted + QtCore.QTimer.singleShot(0, tick) + QtCore.QTimer.singleShot(0, requestQuit) + result = app.run() + assert result == 23, result + assert ticks and entered.is_set() + assert app.runtime._control.closed + assert app.plan._worker is None + assert AppSettings.isStateON_('Connect') + """) + environment = os.environ.copy() + environment['QT_QPA_PLATFORM'] = 'offscreen' + result = subprocess.run( + [sys.executable, '-c', script], + env=environment, + capture_output=True, + text=True, + timeout=15, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def testRepeatedExitMakesOneFinalTerminationRequest(self): application = SimpleNamespace( _exitRequested=False, + _exitCommitted=False, + connectionController=None, _exitCode=ApplicationRunner.ExitCode.ExitSuccess.value, setExitingFlag=mock.Mock(), cleanup=mock.Mock(), ) + application._completeExit = functools.partial( + DesktopApplication._completeExit, application + ) with mock.patch( 'Furious.Application.DesktopApplication.QtCore.QCoreApplication.exit' @@ -1009,6 +1106,8 @@ class ApplicationLifecycleTransactionTest(TestCase): application = SimpleNamespace( mainWindow=mainWindow, systemTray=None, + _exitRequested=True, + _exitCommitted=True, exit=mock.Mock(), ) @@ -1045,6 +1144,53 @@ class ApplicationLifecycleTransactionTest(TestCase): closeEvent.ignore.assert_not_called() self.assertFalse(filtered) + def testMainWindowStaysVisibleWhileQuitIsPendingOrRefused(self): + """A deferred or failed exit must not hide the only usable window.""" + application() + for requested in (True, False): + with self.subTest(requested=requested), isolatedSettings(): + window = QWidget() + owner = SimpleNamespace( + mainWindow=window, + systemTray=None, + exit=mock.Mock(), + _exitRequested=requested, + _exitCommitted=False, + ) + message = mock.Mock() + message.exec.return_value = PySide6Legacy.enumValueWrapper( + AppQMessageBox.StandardButton.Yes + ) + messageType = mock.Mock(return_value=message) + messageType.Icon = AppQMessageBox.Icon + messageType.StandardButton = AppQMessageBox.StandardButton + + class CloseFilter(QtCore.QObject): + def eventFilter(self, watched, event): + if event.type() == QtCore.QEvent.Type.Close: + return DesktopApplication._handleMainWindowCloseEvent( + owner, event + ) + return False + + eventFilter = CloseFilter(window) + window.installEventFilter(eventFilter) + try: + window.show() + processQtEvents() + with mock.patch( + 'Furious.Application.DesktopApplication.AppQMessageBox', + messageType, + ): + window.close() + self.assertTrue(window.isVisible()) + owner.exit.assert_called_once_with() + finally: + window.removeEventFilter(eventFilter) + window.close() + window.deleteLater() + processQtEvents() + def testMainWindowCloseCancellationConsumesEvent(self): """Reject the pending close before the main window disappears.""" messageBox = mock.Mock() @@ -1530,11 +1676,14 @@ class ApplicationLifecycleTransactionTest(TestCase): def _initializeControllers(self): class Controller(QtCore.QObject): + shutdownFinished = QtCore.Signal(bool) + def restoreStartupState(self): pass def shutdown(self): - pass + self.shutdownFinished.emit(True) + return True self.connectionController = Controller(self) self.routingController = QtCore.QObject(self) diff --git a/tests/test_sing_tun.py b/tests/test_sing_tun.py index ead55247..73f45e69 100644 --- a/tests/test_sing_tun.py +++ b/tests/test_sing_tun.py @@ -66,13 +66,15 @@ from Furious.Service.LogManager import LogManager, TUN_LOG_CATEGORY from PySide6 import QtCore from PySide6.QtTest import QTest from PySide6.QtWidgets import QLabel, QStyle, QStyleOptionFrame, QWidget -from shiboken6 import isValid +from shiboken6 import isValid, delete as deleteQObject from tests.support import application, isolatedSettings, processQtEvents, waitFor from tests.test_connection_startup_async import _Configuration, _Registry, _Runtime from unittest import mock from pathlib import Path +from contextlib import contextmanager +from types import SimpleNamespace import copy import importlib @@ -1336,6 +1338,238 @@ class SingTUNChildTest(unittest.TestCase): def setUpClass(cls): application() + @contextmanager + def _delayedLinuxRecovery(self, *, denied=False): + routing = importlib.import_module('Furious.Frozenlib.SingTUNRouting') + controllers = importlib.import_module( + 'Furious.Controllers.ConnectionController' + ) + plan = SingTUNHostPlan({}, platform='Linux') + tun = plan.configuration['tun_options'] + tun['Name'] = 'utun101' + plan._linux = helper = SingTUNLinuxRouting(tun, 123456, 2000, threading.Event()) + helper.index = 77 + helper.dnsTouched = plan._activated = True + entered, authorized = threading.Event(), threading.Event() + present, refused, processes = [True], [denied], [] + + def command(arguments): + if not present[0]: + return '[]' + if arguments == ['ip', '-N', '-j', 'link', 'show']: + return '[{"ifname":"utun101","ifindex":77}]' + if arguments == ['ip', '-4', '-N', '-j', 'rule', 'show']: + return '[{"priority":2001,"src":"all","table":123456,"suppress_prefixlen":0}]' + return '[]' + + def launch(arguments, **kwargs): + self.assertEqual(arguments[:2], ['pkexec', 'bash']) + process = mock.Mock() + process.poll.return_value = None + + def wait(timeout=None): + self.assertIsNone(timeout) + entered.set() + if not authorized.wait(10): + raise RuntimeError('fixture authorization was not released') + code = 126 if refused[0] else 0 + process.poll.return_value = code + if not code: + present[0] = False + return code + + process.wait.side_effect = wait + processes.append(process) + return process + + with isolatedSettings(), mock.patch.object( + routing, '_command', side_effect=command + ), mock.patch.object( + routing.os, 'geteuid', return_value=1000, create=True + ), mock.patch.object( + routing.subprocess, 'Popen', side_effect=launch + ), mock.patch.object( + controllers.SystemProxy, 'off' + ), mock.patch.object( + controllers.Mixins.ConnectionAware, 'callDisconnectedCallback' + ), mock.patch( + 'sys.excepthook' + ) as qtErrors: + manager = ConnectionManager() + runtime = SingTUN({}, hostPlan=plan) + router = RuntimeEventRouter() + router.attach(runtime, manager) + lease = RuntimeLease(runtime, router, applicationTUN=True) + manager._leases.append(lease) + controller = ConnectionController( + coreManager=manager, updatesManager=mock.Mock() + ) + controller._state = ConnectionState.Connected + try: + yield SimpleNamespace( + controller=controller, + manager=manager, + runtime=runtime, + lease=lease, + plan=plan, + helper=helper, + entered=entered, + authorized=authorized, + processes=processes, + refused=refused, + present=present, + ) + finally: + refused[0] = False + authorized.set() + operation = manager.stopAsync() + operation.start() + self.assertTrue(waitFor(lambda: operation._terminal, timeout=10)) + manager.cleanup() + controller.deleteLater() + processQtEvents() + qtErrors.assert_not_called() + + def testDisconnectAuthorizationLeavesQtResponsiveAndKeepsItsLease(self): + with self._delayedLinuxRecovery() as fixture: + controller, runtime = fixture.controller, fixture.runtime + states, ticks = [], [] + controller.stateChanged.connect(states.append) + self.assertTrue(controller.startDisconnection()) + self.assertTrue(fixture.entered.wait(2)) + QtCore.QTimer.singleShot(0, lambda: ticks.append(True)) + self.assertTrue(waitFor(lambda: bool(ticks))) + self.assertEqual(controller.state, ConnectionState.Disconnecting) + self.assertTrue(runtime.cleanupPending) + self.assertFalse(runtime._control.closed) + self.assertEqual(fixture.manager._pendingReleases, [fixture.lease]) + self.assertEqual(len(fixture.processes), 1) + with self.assertRaisesRegex(RuntimeError, 'cleanup is incomplete'): + fixture.manager.startAsync(None, '') + + fixture.authorized.set() + self.assertTrue( + waitFor(lambda: controller.state is ConnectionState.Disconnected) + ) + self.assertEqual( + states, [ConnectionState.Disconnecting, ConnectionState.Disconnected] + ) + self.assertFalse(runtime.cleanupPending) + self.assertTrue(runtime._control.closed) + self.assertIsNone(fixture.plan._worker) + self.assertIsNone(fixture.helper._process) + self.assertEqual(fixture.manager._pendingReleases, []) + + def testCancelledRecoveryAuthorizationIsAFailureWithoutAnAutomaticSecondPrompt( + self, + ): + with self._delayedLinuxRecovery(denied=True) as fixture: + fixture.controller.startDisconnection() + self.assertTrue(fixture.entered.wait(2)) + fixture.authorized.set() + self.assertTrue( + waitFor( + lambda: fixture.controller.state is ConnectionState.Disconnected + ) + ) + self.assertTrue(fixture.controller._runtimeCleanupFailed) + self.assertEqual(len(fixture.processes), 1) + self.assertEqual(fixture.manager._pendingReleases, [fixture.lease]) + self.assertFalse(fixture.runtime._control.closed) + + def testReconnectWaitsForHostReleaseAndIsRequestedOnce(self): + with self._delayedLinuxRecovery() as fixture, mock.patch.object( + fixture.controller, 'startConnection', return_value=True + ) as reconnect: + fixture.controller.startReconnection() + self.assertTrue(fixture.entered.wait(2)) + processQtEvents() + reconnect.assert_not_called() + fixture.authorized.set() + self.assertTrue(waitFor(lambda: reconnect.call_count == 1)) + self.assertEqual(fixture.manager._pendingReleases, []) + + def testDestroyingTheCleanupObserverRetainsItsWorkerForRetry(self): + with self._delayedLinuxRecovery() as fixture: + fixture.controller.startDisconnection() + self.assertTrue(fixture.entered.wait(2)) + operation = fixture.controller._stopOperation + deleteQObject(operation) + self.assertFalse(isValid(operation._timer)) + self.assertIsNone(fixture.manager._activeStopOperation) + self.assertEqual(fixture.manager._pendingReleases, [fixture.lease]) + self.assertEqual(fixture.controller.state, ConnectionState.Disconnected) + self.assertIsNotNone(fixture.helper._process) + + def testShutdownWaitsAsynchronouslyAndPreservesTheStartupPreference(self): + with self._delayedLinuxRecovery() as fixture: + AppSettings.turnON_('Connect') + results, ticks = [], [] + fixture.controller.shutdownFinished.connect(results.append) + self.assertIs(fixture.controller.shutdown(), False) + self.assertTrue(fixture.entered.wait(2)) + QtCore.QTimer.singleShot(0, lambda: ticks.append(True)) + self.assertTrue(waitFor(lambda: bool(ticks))) + self.assertEqual(results, []) + self.assertTrue(AppSettings.isStateON_('Connect')) + fixture.authorized.set() + self.assertTrue(waitFor(lambda: results == [True])) + self.assertTrue(AppSettings.isStateON_('Connect')) + self.assertIs(fixture.controller.shutdown(), True) + self.assertEqual(len(fixture.processes), 1) + + def testCancellingPreparationWaitsForItsWorkerWithoutBlockingQt(self): + controllers = importlib.import_module( + 'Furious.Controllers.ConnectionController' + ) + entered, released = threading.Event(), threading.Event() + plan = SingTUNHostPlan({}, platform='Linux') + + def prepare(_addresses): + entered.set() + if not released.wait(5): + raise RuntimeError('fixture preparation was not released') + + with isolatedSettings(), mock.patch.object( + plan, 'prepare', side_effect=prepare + ), mock.patch.object(controllers.SystemProxy, 'off'), mock.patch.object( + controllers.Mixins.ConnectionAware, 'callDisconnectedCallback' + ): + manager = ConnectionManager() + runtime = SingTUN({}, hostPlan=plan) + router = RuntimeEventRouter() + router.attach(runtime, manager) + lease = RuntimeLease(runtime, router, applicationTUN=True) + manager._leases.append(lease) + controller = ConnectionController( + coreManager=manager, updatesManager=mock.Mock() + ) + controller._state = ConnectionState.Connecting + plan.begin('prepare', ['192.0.2.1']) + + try: + self.assertTrue(entered.wait(2)) + controller.startDisconnection() + ticks = [] + QtCore.QTimer.singleShot(0, lambda: ticks.append(True)) + self.assertTrue(waitFor(lambda: bool(ticks))) + self.assertEqual(controller.state, ConnectionState.Disconnecting) + self.assertEqual(manager._pendingReleases, [lease]) + released.set() + self.assertTrue( + waitFor(lambda: controller.state is ConnectionState.Disconnected) + ) + self.assertIsNone(plan._worker) + self.assertTrue(runtime._control.closed) + finally: + released.set() + cleanup = manager.stopAsync() + cleanup.start() + self.assertTrue(waitFor(lambda: cleanup._terminal)) + manager.cleanup() + controller.deleteLater() + processQtEvents() + def testProductionSpawnImportsBindingAndStopsCooperatively(self): """Exercise the production importer without opening a native TUN.""" with tempfile.TemporaryDirectory() as directory: