Fix application TUN reconnect notices

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-06 23:57:16 +08:00
parent 79ab0b146a
commit 6380eb9f30
9 changed files with 192 additions and 9 deletions
+3
View File
@@ -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
+8 -1
View File
@@ -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):
+2
View File
@@ -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,
+12 -6
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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. |
+3
View File
@@ -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."""
+151
View File
@@ -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()