Keep disconnect and quit responsive

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-10 20:34:30 +08:00
parent 6c5cd5c22f
commit dfcdfd254f
4 changed files with 558 additions and 11 deletions
+51 -4
View File
@@ -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()
+122 -5
View File
@@ -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')
+150 -1
View File
@@ -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)
+235 -1
View File
@@ -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: