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
+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: