From 6380eb9f301aa2e54d1cbe7fb939637621d075d6 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Tue, 6 Oct 2026 23:57:16 +0800 Subject: [PATCH] Fix application TUN reconnect notices Signed-off-by: Loren Eteval --- Furious/Controllers/AGENTS.md | 3 + Furious/Controllers/ConnectionController.py | 4 + Furious/Controllers/SettingsController.py | 9 +- Furious/Service/AGENTS.md | 2 + Furious/Service/ConnectionManager.py | 18 ++- Furious/Service/RuntimeLease.py | 9 +- tests/README.md | 2 +- tests/test_connection_startup_async.py | 3 + tests/test_sing_tun.py | 151 ++++++++++++++++++++ 9 files changed, 192 insertions(+), 9 deletions(-) diff --git a/Furious/Controllers/AGENTS.md b/Furious/Controllers/AGENTS.md index 285f6bf4..fa74d67a 100644 --- a/Furious/Controllers/AGENTS.md +++ b/Furious/Controllers/AGENTS.md @@ -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 diff --git a/Furious/Controllers/ConnectionController.py b/Furious/Controllers/ConnectionController.py index 5c667113..ef5a962b 100644 --- a/Furious/Controllers/ConnectionController.py +++ b/Furious/Controllers/ConnectionController.py @@ -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 diff --git a/Furious/Controllers/SettingsController.py b/Furious/Controllers/SettingsController.py index 8a42c43f..6669a8fa 100644 --- a/Furious/Controllers/SettingsController.py +++ b/Furious/Controllers/SettingsController.py @@ -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): diff --git a/Furious/Service/AGENTS.md b/Furious/Service/AGENTS.md index dccca935..798a7c2c 100644 --- a/Furious/Service/AGENTS.md +++ b/Furious/Service/AGENTS.md @@ -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, diff --git a/Furious/Service/ConnectionManager.py b/Furious/Service/ConnectionManager.py index b104e25c..8b0700ca 100644 --- a/Furious/Service/ConnectionManager.py +++ b/Furious/Service/ConnectionManager.py @@ -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 diff --git a/Furious/Service/RuntimeLease.py b/Furious/Service/RuntimeLease.py index 0ae0afe0..59c5ce40 100644 --- a/Furious/Service/RuntimeLease.py +++ b/Furious/Service/RuntimeLease.py @@ -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 diff --git a/tests/README.md b/tests/README.md index 561be9e0..753fd755 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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. | diff --git a/tests/test_connection_startup_async.py b/tests/test_connection_startup_async.py index f6566d75..75f05652 100644 --- a/tests/test_connection_startup_async.py +++ b/tests/test_connection_startup_async.py @@ -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.""" diff --git a/tests/test_sing_tun.py b/tests/test_sing_tun.py index ee8645c4..6b4f9a46 100644 --- a/tests/test_sing_tun.py +++ b/tests/test_sing_tun.py @@ -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()