mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-13 01:38:33 +03:00
Keep disconnect and quit responsive
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user