mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-09 15:19:56 +03:00
Fix application TUN reconnect notices
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -44,6 +44,9 @@ compatibility paths.
|
||||
TUN mode, application-engine preference, and the active runtime's captured choice are separate values. A preference
|
||||
change affects a subsequent attempt; it neither replaces a live engine nor overrides proxy-core native TUN.
|
||||
Registration/defaults and selection signals belong here, while customization storage and host work keep their owners.
|
||||
Application-engine reconnect notices consult committed application-TUN ownership. Native-TUN and proxy-only
|
||||
connections still save and publish the engine preference without requesting reconnection; see the preference-notice
|
||||
cases in `tests/test_sing_tun.py`.
|
||||
- A completed disconnect restores usable UI state even if runtime cleanup failed. `Disconnected` and an empty
|
||||
active-runtime snapshot therefore do not prove physical release: the service retains failed leases and blocks
|
||||
new acquisition while they remain. Final controller shutdown surfaces unresolved cleanup and preserves the
|
||||
|
||||
@@ -149,6 +149,10 @@ class ConnectionController(QtCore.QObject):
|
||||
"""Return whether the connection is established."""
|
||||
return self.state is ConnectionState.Connected
|
||||
|
||||
def usesApplicationTUN(self) -> bool:
|
||||
"""Read application TUN ownership from the committed runtime manager."""
|
||||
return self._coreManager.usesApplicationTUN()
|
||||
|
||||
def isConnecting(self) -> bool:
|
||||
"""Return whether the connection is starting."""
|
||||
return self.state is ConnectionState.Connecting
|
||||
|
||||
@@ -203,7 +203,14 @@ class SettingsController(QtCore.QObject):
|
||||
|
||||
self.tunBackendChanged.emit(backend)
|
||||
|
||||
showMBoxNewChangesNextTime()
|
||||
connection = AppConnectionController()
|
||||
|
||||
if (
|
||||
connection is not None
|
||||
and connection.isConnected()
|
||||
and connection.usesApplicationTUN()
|
||||
):
|
||||
showMBoxNewChangesNextTime()
|
||||
|
||||
@staticmethod
|
||||
def setApplicationTheme(theme: ApplicationTheme | str):
|
||||
|
||||
@@ -39,6 +39,8 @@ for execution, and Qt for lifetime primitives. This scope owns multi-stage workf
|
||||
`usesApplicationTun2socks` still means application-TUN eligibility; it must not force the selected engine.
|
||||
Snapshot the engine and relevant customization once per attempt; bind each backend's named settings callers to
|
||||
that snapshot, preserving proxy-only and explicit native TUN behavior.
|
||||
Committed application-TUN usage is derived from the runtime leases marked at acquisition, not from mutable profile
|
||||
documents or next-start settings. Settings presentation must not run TUN preparation to decide whether to reconnect.
|
||||
- Each application engine reads only its own persisted document: sing-tun's `host_options` belong to
|
||||
`CustomSingTUNSettings`, while tun2socks uses `CustomTUNSettings`; edits and missing defaults must never import
|
||||
preferences from the other engine. Their repository/host policies remain independent while orchestration,
|
||||
|
||||
@@ -122,13 +122,15 @@ class _ConnectionStartAttempt:
|
||||
"""Return an ordered execution-resource snapshot for this attempt."""
|
||||
return [lease.runtime for lease in self.leases]
|
||||
|
||||
def ownRuntime(self, runtime: CoreRuntime | None, router=None):
|
||||
def ownRuntime(
|
||||
self, runtime: CoreRuntime | None, router=None, *, applicationTUN=False
|
||||
):
|
||||
"""Retain one exact runtime lease until the attempt commits."""
|
||||
if runtime is None:
|
||||
return None
|
||||
|
||||
router = router or RuntimeEventRouter()
|
||||
lease = RuntimeLease(runtime, router)
|
||||
lease = RuntimeLease(runtime, router, applicationTUN=applicationTUN)
|
||||
|
||||
self.leases.append(lease)
|
||||
|
||||
@@ -784,7 +786,7 @@ class ConnectionStartOperation(QtCore.QObject):
|
||||
router.attach(tun, self)
|
||||
|
||||
self._tun = tun
|
||||
self.attempt.ownRuntime(tun, router)
|
||||
self.attempt.ownRuntime(tun, router, applicationTUN=True)
|
||||
self._startTUN = tun.start
|
||||
|
||||
if PLATFORM != 'Linux':
|
||||
@@ -831,7 +833,7 @@ class ConnectionStartOperation(QtCore.QObject):
|
||||
|
||||
router.attach(self._tun, self)
|
||||
|
||||
self.attempt.ownRuntime(self._tun, router)
|
||||
self.attempt.ownRuntime(self._tun, router, applicationTUN=True)
|
||||
|
||||
address = self.attempt.runtimeConfiguration.remoteAddress()
|
||||
|
||||
@@ -1376,6 +1378,10 @@ class ConnectionManager(Mixins.CleanupOnExit):
|
||||
"""Return committed runtimes without exposing the mutable owner list."""
|
||||
return [lease.runtime for lease in self._leases]
|
||||
|
||||
def usesApplicationTUN(self) -> bool:
|
||||
"""Return whether the committed connection owns an application TUN engine."""
|
||||
return any(lease.applicationTUN for lease in self._leases)
|
||||
|
||||
def _connectionDnsResolver(self) -> DnsResolver:
|
||||
"""Return the resolver owned by this connection-manager lifecycle."""
|
||||
if self._dnsResolver is None:
|
||||
@@ -1701,7 +1707,7 @@ class ConnectionManager(Mixins.CleanupOnExit):
|
||||
try:
|
||||
runtime = self._createSingTUN(attempt, exitCallback, messageCallback)
|
||||
|
||||
attempt.ownRuntime(runtime)
|
||||
attempt.ownRuntime(runtime, applicationTUN=True)
|
||||
|
||||
address = attempt.runtimeConfiguration.remoteAddress()
|
||||
|
||||
@@ -1849,7 +1855,7 @@ class ConnectionManager(Mixins.CleanupOnExit):
|
||||
msgCallback=msgCallbackTUN_,
|
||||
)
|
||||
|
||||
attempt.ownRuntime(tun)
|
||||
attempt.ownRuntime(tun, applicationTUN=True)
|
||||
|
||||
startTUN = tun.start
|
||||
|
||||
|
||||
@@ -164,13 +164,20 @@ class RuntimeEventRouter(QtCore.QObject):
|
||||
class RuntimeLease:
|
||||
"""Own one runtime and its stable event router through transfer and release."""
|
||||
|
||||
def __init__(self, runtime: CoreRuntime, router: RuntimeEventRouter):
|
||||
def __init__(
|
||||
self,
|
||||
runtime: CoreRuntime,
|
||||
router: RuntimeEventRouter,
|
||||
*,
|
||||
applicationTUN: bool = False,
|
||||
):
|
||||
"""Take exact ownership of one attached runtime."""
|
||||
if not isinstance(runtime, CoreRuntime):
|
||||
raise TypeError('runtime lease requires a CoreRuntime')
|
||||
|
||||
self.runtime = runtime
|
||||
self.router = router
|
||||
self.applicationTUN = applicationTUN
|
||||
self._releaseInProgress = False
|
||||
|
||||
@property
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ worker. Choose tests by the changed contract rather than by filename alone.
|
||||
| [test_external_core.py](test_external_core.py) | Harmless real process launch/output/shutdown, partial thread-start rollback, non-finite timeout rejection, failed reap/thread-join retry, readiness/TUN metadata, Windows paths with spaces, subscription rejection of executable profiles, bounded DNS references. |
|
||||
| [test_frozenlib.py](test_frozenlib.py) | Nested state guards, cleanup isolation, native peer destruction during connection/theme/translation/cleanup notifications, bounded caches/throttling, dual-stack probe selection, mocked proxy/DNS/routes/startup/session boundaries and failure handling. |
|
||||
| [test_native_tun_semantics.py](test_native_tun_semantics.py) | Xray/Hysteria2 runtime-copy TUN preservation/replacement, managed-TUN failures, download-test stripping, prevention of a second tun2socks owner. |
|
||||
| [test_sing_tun.py](test_sing_tun.py) | tun2socks engine default, saved engine choices, sing-tun Go stack default, development-stack and congestion-control compatibility, independent backend settings and host defaults, selected-only snapshots, captured TUN log sources in both startup paths, staged settings and malformed storage, SOCKS authentication/UDP, harmless real spawn/status/stop, native readiness, cancellation, synchronous compatibility, mocked DNS/routing ownership including development IPv6 rule ordering and refused cleanup. |
|
||||
| [test_sing_tun.py](test_sing_tun.py) | tun2socks engine default, saved engine choices and ownership-aware reconnect notices, sing-tun Go stack default, development-stack and congestion-control compatibility, independent backend settings and host defaults, selected-only snapshots, captured TUN log sources in both startup paths, staged settings and malformed storage, SOCKS authentication/UDP, harmless real spawn/status/stop, native readiness, cancellation, synchronous compatibility, mocked DNS/routing ownership including development IPv6 rule ordering and refused cleanup. |
|
||||
| [test_subscription_sync.py](test_subscription_sync.py) | Group-local preparation/commit, stable duplicate identity, atomic failure, preservation of newer local metadata, rejection of changed source state. |
|
||||
| [test_subscription_manager.py](test_subscription_manager.py) | Stable request generations, provider metadata, batch/partial failures, timer policy, targeted updates, real Stop Updates input, worker responsiveness, cancellation and synchronous shutdown ownership. |
|
||||
| [test_subscription_scalability.py](test_subscription_scalability.py) | Deterministic 1/3/8-group preparation and commit with 1,500 profiles per group and bounded workers; uses the offline benchmark helper. |
|
||||
|
||||
@@ -922,6 +922,7 @@ class ConnectionStartupAsyncTest(TestCase):
|
||||
['find-device', 'script', 'find-device', 'tun-start'],
|
||||
)
|
||||
self.assertEqual(manager.runtimes, [primary, tun])
|
||||
self.assertTrue(manager.usesApplicationTUN())
|
||||
self.assertEqual(tun.startOptions[0], {})
|
||||
|
||||
def testWindowsTunStartsRuntimeBeforeObservingAndMutatingDevice(self):
|
||||
@@ -1047,6 +1048,7 @@ class ConnectionStartupAsyncTest(TestCase):
|
||||
['tun-start', 'find-device', 'add-relations'],
|
||||
)
|
||||
self.assertEqual(manager.runtimes, [primary, tun])
|
||||
self.assertTrue(manager.usesApplicationTUN())
|
||||
|
||||
def testDarwinTunSurvivalPrecedesDnsAndRouteMutation(self):
|
||||
"""Observe tun2socks survival before applying macOS host networking."""
|
||||
@@ -1157,6 +1159,7 @@ class ConnectionStartupAsyncTest(TestCase):
|
||||
|
||||
self.assertEqual(events, ['tun-start', 'read-dns'])
|
||||
self.assertEqual(manager.runtimes, [primary, tun])
|
||||
self.assertTrue(manager.usesApplicationTUN())
|
||||
|
||||
def testRepeatedCancellationReleasesOperationTimersAndRuntimes(self):
|
||||
"""Keep repeated startup cancellation bounded and independently owned."""
|
||||
|
||||
@@ -47,6 +47,10 @@ from Furious.Window.SingTUNSettingsDialog import SingTUNSettingsDialog
|
||||
from Furious.Window.TunSettingsDialog import TunSettingsDialog
|
||||
from Furious.Backends.Configuration import ConfigXray, ConfigHysteria1, ConfigHysteria2
|
||||
from Furious.Controllers.SettingsController import SettingsController
|
||||
from Furious.Controllers.ConnectionController import (
|
||||
ConnectionController,
|
||||
ConnectionState,
|
||||
)
|
||||
from Furious.Qt import gettext as _
|
||||
from Furious.Service.RuntimeLease import RuntimeEventRouter, RuntimeLeaseState
|
||||
from Furious.Service.LogManager import LogManager, TUN_LOG_CATEGORY
|
||||
@@ -794,6 +798,145 @@ class SingTUNUIAndStorageTest(unittest.TestCase):
|
||||
|
||||
self.assertFalse(isValid(dialog))
|
||||
|
||||
def testNativeTUNBackendSelectionPersistsWithoutReconnectNotice(self):
|
||||
"""Application-engine preferences do not affect an active native TUN."""
|
||||
module = importlib.import_module('Furious.Window.SettingsPage')
|
||||
configurations = (
|
||||
ConfigXray({'inbounds': []}),
|
||||
ConfigXray({'inbounds': [{'protocol': 'tun'}]}),
|
||||
ConfigHysteria2({}),
|
||||
ConfigHysteria2({'tun': {}}),
|
||||
)
|
||||
|
||||
for configuration in configurations:
|
||||
with (
|
||||
self.subTest(configuration=dict(configuration)),
|
||||
isolatedSettings(),
|
||||
mock.patch(
|
||||
'Furious.Controllers.SettingsController.showMBoxNewChangesNextTime'
|
||||
) as notice,
|
||||
mock.patch('sys.excepthook') as callbackExceptionHook,
|
||||
):
|
||||
AppSettings.turnON_('VPNMode')
|
||||
AppSettings.set('ApplicationTUNBackend', 'tun2socks')
|
||||
manager = ConnectionManager()
|
||||
connection = ConnectionController(
|
||||
coreManager=manager, updatesManager=mock.Mock()
|
||||
)
|
||||
settings = SettingsController()
|
||||
previousConnection = self.app.connectionController
|
||||
self.app.connectionController = connection
|
||||
card = None
|
||||
|
||||
try:
|
||||
primary = _Runtime()
|
||||
primary.start()
|
||||
attempt = _ConnectionStartAttempt(
|
||||
manager, configuration, nativeTUNHandled=True
|
||||
)
|
||||
attempt.ownRuntime(primary)
|
||||
attempt.commit()
|
||||
connection._activeProfile = configuration
|
||||
connection._state = ConnectionState.Connected
|
||||
changes = []
|
||||
settings.tunBackendChanged.connect(changes.append)
|
||||
|
||||
with mock.patch.object(
|
||||
module, 'AppSettingsController', return_value=settings
|
||||
):
|
||||
card = module._TUNBackendSettingsCard()
|
||||
|
||||
for backend in ('sing-tun', 'tun2socks'):
|
||||
card.comboBox.setCurrentIndex(
|
||||
card.comboBox.findData(backend)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
AppSettings.get('ApplicationTUNBackend'), backend
|
||||
)
|
||||
self.assertEqual(card.comboBox.currentData(), backend)
|
||||
self.assertTrue(connection.isConnected())
|
||||
self.assertTrue(primary.isRunning())
|
||||
self.assertEqual(manager.runtimes, [primary])
|
||||
notice.assert_not_called()
|
||||
|
||||
self.assertEqual(changes, ['sing-tun', 'tun2socks'])
|
||||
finally:
|
||||
self.app.connectionController = previousConnection
|
||||
|
||||
if card is not None:
|
||||
card.deleteLater()
|
||||
|
||||
manager.cleanup()
|
||||
connection.deleteLater()
|
||||
settings.deleteLater()
|
||||
processQtEvents()
|
||||
|
||||
callbackExceptionHook.assert_not_called()
|
||||
|
||||
def testApplicationTUNBackendSelectionStillOffersReconnect(self):
|
||||
"""Use committed engine ownership even if the profile is edited later."""
|
||||
for backend in ('tun2socks', 'sing-tun'):
|
||||
with (
|
||||
self.subTest(backend=backend),
|
||||
isolatedSettings(),
|
||||
mock.patch(
|
||||
'Furious.Controllers.SettingsController.showMBoxNewChangesNextTime'
|
||||
) as notice,
|
||||
):
|
||||
AppSettings.set('ApplicationTUNBackend', backend)
|
||||
manager = ConnectionManager()
|
||||
connection = ConnectionController(
|
||||
coreManager=manager, updatesManager=mock.Mock()
|
||||
)
|
||||
settings = SettingsController()
|
||||
previousConnection = self.app.connectionController
|
||||
self.app.connectionController = connection
|
||||
configuration = ConfigXray({'inbounds': []})
|
||||
attempt = _ConnectionStartAttempt(
|
||||
manager, configuration, applicationTun2socks=True
|
||||
)
|
||||
|
||||
try:
|
||||
primary, engine = _Runtime(), _Runtime()
|
||||
primary.start()
|
||||
engine.start()
|
||||
attempt.ownRuntime(primary)
|
||||
attempt.ownRuntime(engine, applicationTUN=True)
|
||||
|
||||
self.assertFalse(manager.usesApplicationTUN())
|
||||
|
||||
attempt.commit()
|
||||
connection._activeProfile = configuration
|
||||
connection._state = ConnectionState.Connected
|
||||
configuration['inbounds'].append({'protocol': 'tun'})
|
||||
|
||||
self.assertTrue(connection.usesApplicationTUN())
|
||||
|
||||
other = 'sing-tun' if backend == 'tun2socks' else 'tun2socks'
|
||||
settings.setTUNBackend(other)
|
||||
settings.setTUNBackend(other)
|
||||
|
||||
notice.assert_called_once_with()
|
||||
self.assertEqual(AppSettings.get('ApplicationTUNBackend'), other)
|
||||
self.assertEqual(manager.runtimes, [primary, engine])
|
||||
self.assertTrue(primary.isRunning())
|
||||
self.assertTrue(engine.isRunning())
|
||||
|
||||
notice.reset_mock()
|
||||
connection._state = ConnectionState.Disconnected
|
||||
settings.setTUNBackend(backend)
|
||||
notice.assert_not_called()
|
||||
finally:
|
||||
self.app.connectionController = previousConnection
|
||||
attempt.rollback()
|
||||
manager.cleanup()
|
||||
connection.deleteLater()
|
||||
settings.deleteLater()
|
||||
processQtEvents()
|
||||
|
||||
self.assertFalse(manager.usesApplicationTUN())
|
||||
|
||||
def testPreferenceDefaultInvalidAndSignal(self):
|
||||
with isolatedSettings(), mock.patch(
|
||||
'Furious.Controllers.SettingsController.showMBoxNewChangesNextTime'
|
||||
@@ -1534,6 +1677,7 @@ class SingTUNStartupTest(unittest.TestCase):
|
||||
stored.assert_not_called()
|
||||
registry.usesApplicationTun2socks.assert_not_called()
|
||||
self.assertEqual(primary.isRunning(), not failure)
|
||||
self.assertFalse(manager.usesApplicationTUN())
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
@@ -1622,6 +1766,7 @@ class SingTUNStartupTest(unittest.TestCase):
|
||||
runtime = operation._tun
|
||||
self.assertFalse(succeeded)
|
||||
self.assertEqual(manager.runtimes, [])
|
||||
self.assertFalse(manager.usesApplicationTUN())
|
||||
|
||||
AppSettings.set('ApplicationTUNBackend', 'tun2socks')
|
||||
customized['host_options']['bypassTUNAdapterInterfaceIP'] = '192.0.2.99'
|
||||
@@ -1632,11 +1777,14 @@ class SingTUNStartupTest(unittest.TestCase):
|
||||
|
||||
runtime.nativeReady = True
|
||||
self.assertTrue(waitFor(lambda: bool(succeeded)))
|
||||
self.assertTrue(manager.usesApplicationTUN())
|
||||
self.assertEqual(runtime._hostPlan.applied, ['utun101'])
|
||||
self.assertEqual(runtime._hostPlan.addresses, ['192.0.2.1', '2001:db8::1'])
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
self.assertFalse(manager.usesApplicationTUN())
|
||||
|
||||
processQtEvents()
|
||||
|
||||
def testCancellationBeforeNativeReadyReleasesBothOwners(self):
|
||||
@@ -1708,9 +1856,12 @@ class SingTUNStartupTest(unittest.TestCase):
|
||||
|
||||
self.assertTrue(manager.start(_Configuration(), 'Global', deepcopy=False))
|
||||
self.assertEqual(manager.runtimes[1]._hostPlan.applied, ['utun101'])
|
||||
self.assertTrue(manager.usesApplicationTUN())
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
self.assertFalse(manager.usesApplicationTUN())
|
||||
|
||||
def testSocksOnlyResolverHasNoHTTPOrDirectFallback(self):
|
||||
resolver = mock.Mock()
|
||||
configuration = _Configuration()
|
||||
|
||||
Reference in New Issue
Block a user