fix(app): harden session runtime lifecycle (#18)
Scope asynchronous connect, disconnect, sync, and reattachment work to the active session so stale work cannot mutate newer VPN state.
This commit was merged in pull request #18.
This commit is contained in:
1 parent
321bbae44d
commit
ba89e35442
2 files changed
+201
-165
No files matched your search
+114
-89
@@ -1,12 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_vless/flutter_vless.dart';
|
||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
||||
import 'package:revoltvpn/logic/network_monitor.dart';
|
||||
import 'package:revoltvpn/logic/power_settings.dart';
|
||||
import 'package:revoltvpn/logic/secure_socks_session.dart';
|
||||
|
||||
enum VpnStatus {
|
||||
@@ -18,7 +18,12 @@ enum VpnStatus {
|
||||
}
|
||||
|
||||
class VpnConnection extends ChangeNotifier {
|
||||
bool _cancelled = false;
|
||||
static const MethodChannel _nativeControl = MethodChannel('flutter_vless');
|
||||
|
||||
int _connectEpoch = 0;
|
||||
bool _suppressNativeConnect = false;
|
||||
bool _disposed = false;
|
||||
|
||||
VpnStatus _status = VpnStatus.disconnected;
|
||||
VpnStatus get status => _status;
|
||||
|
||||
@@ -45,6 +50,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
SecureSocksSession? get activeSocksSession => _lastSecureSocks;
|
||||
|
||||
bool get canTestActiveLocalSocks =>
|
||||
!_disposed &&
|
||||
_status == VpnStatus.connected &&
|
||||
_activeMode == ConnectionMode.proxy &&
|
||||
_lastSecureSocks != null;
|
||||
@@ -94,9 +100,10 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _startEngine() async {
|
||||
if (kIsWeb) return;
|
||||
if (kIsWeb || _disposed) return;
|
||||
|
||||
await ConnectionSettings.initialize();
|
||||
if (_disposed) return;
|
||||
_activeMode = ConnectionSettings.mode;
|
||||
|
||||
_vless = FlutterVless(
|
||||
@@ -115,13 +122,16 @@ class VpnConnection extends ChangeNotifier {
|
||||
notificationIconResourceType: 'drawable',
|
||||
notificationIconResourceName: 'notification_status_icon',
|
||||
);
|
||||
if (_disposed) return;
|
||||
_initialized = true;
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
|
||||
}
|
||||
if (_disposed) return;
|
||||
|
||||
_networkSubscription = NetworkMonitor.changes.listen(
|
||||
(snapshot) {
|
||||
if (_disposed) return;
|
||||
// ConnectivityManager is informational only. Restarting from every
|
||||
// callback caused the old VPN-created-network reconnect loop.
|
||||
_networkTransport = snapshot.transport;
|
||||
@@ -139,32 +149,10 @@ class VpnConnection extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
final coreVersion = await _vless.getCoreVersion();
|
||||
debugPrint('[VPN] Xray core version: $coreVersion');
|
||||
} catch (_) {}
|
||||
|
||||
await _adoptRunningRuntime();
|
||||
}
|
||||
|
||||
/// Reflect a tunnel that is already up when the UI starts.
|
||||
///
|
||||
/// This asks the OS whether our VPN service process is alive, rather than
|
||||
/// probing a local SOCKS port. The old probe called getConnectedServerDelay(),
|
||||
/// which reads AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807 — but
|
||||
/// AppConfigs lives in the VPN's own process, so from here it is always null
|
||||
/// and the port always fell back to 10807. That was correct only while the
|
||||
/// SOCKS port was fixed; per-session ephemeral ports made it permanently
|
||||
/// wrong, so the app reported "connected" against a dead tunnel.
|
||||
Future<void> _adoptRunningRuntime() async {
|
||||
if (kIsWeb) return;
|
||||
try {
|
||||
final state = await PowerSettings.runtimeState();
|
||||
if (state.aliveFor(tunMode: _activeMode == ConnectionMode.tun)) {
|
||||
_adoptedRunningRuntime = true;
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
if (!_disposed) {
|
||||
debugPrint('[VPN] Xray core version: $coreVersion');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Runtime liveness check failed: $e');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String get _connectedLabel {
|
||||
@@ -176,9 +164,15 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCurrentConnect(int epoch) =>
|
||||
!_disposed && epoch == _connectEpoch && !_userDisconnecting;
|
||||
|
||||
void _mapStatus(VlessStatus status) {
|
||||
if (_disposed) return;
|
||||
switch (status.connectionState) {
|
||||
case VlessConnectionState.connected:
|
||||
if (_suppressNativeConnect || _userDisconnecting) return;
|
||||
if (_connectEpoch == 0) _adoptedRunningRuntime = true;
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
break;
|
||||
|
||||
@@ -188,6 +182,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
break;
|
||||
|
||||
case VlessConnectionState.connecting:
|
||||
if (_suppressNativeConnect || _userDisconnecting) return;
|
||||
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
||||
break;
|
||||
|
||||
@@ -196,6 +191,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
break;
|
||||
|
||||
case VlessConnectionState.unknown:
|
||||
if (_suppressNativeConnect || _userDisconnecting) return;
|
||||
if (_status != VpnStatus.connected &&
|
||||
_status != VpnStatus.disconnected) {
|
||||
_setStatus(VpnStatus.error, 'Connection failed');
|
||||
@@ -205,27 +201,34 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<bool> connect({bool skipAdBypass = false}) async {
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||
if (_disposed ||
|
||||
_userDisconnecting ||
|
||||
_status == VpnStatus.connected ||
|
||||
_status == VpnStatus.connecting ||
|
||||
_status == VpnStatus.disconnecting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_cancelled = false;
|
||||
final connectEpoch = ++_connectEpoch;
|
||||
_suppressNativeConnect = false;
|
||||
_userDisconnecting = false;
|
||||
|
||||
await ConnectionSettings.initialize();
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_activeMode = ConnectionSettings.mode;
|
||||
|
||||
if (!kIsWeb && !_initialized) {
|
||||
_suppressNativeConnect = true;
|
||||
_errorMessage = 'VPN service unavailable.';
|
||||
_setStatus(VpnStatus.error, 'Service unavailable');
|
||||
return false;
|
||||
}
|
||||
|
||||
// TUN needs the Android VPN permission; SOCKS5 proxy mode runs a local
|
||||
// authenticated proxy without a VpnService, so no VPN permission is needed.
|
||||
if (!kIsWeb && _activeMode == ConnectionMode.tun) {
|
||||
final ok = await _vless.requestPermission();
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
if (!ok) {
|
||||
_suppressNativeConnect = true;
|
||||
_errorMessage = 'VPN permission denied.';
|
||||
_setStatus(VpnStatus.error, 'Permission required');
|
||||
return false;
|
||||
@@ -241,6 +244,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
|
||||
if (kIsWeb) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_setStatus(VpnStatus.connected, 'Secured (dev mode)');
|
||||
return true;
|
||||
}
|
||||
@@ -252,6 +256,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
realUrl = await HivemindService.fetchConfigDirectly(
|
||||
skipAdBypass: skipAdBypass,
|
||||
onAttempt: (attempt, total) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return;
|
||||
_setStatus(
|
||||
VpnStatus.connecting,
|
||||
'Contacting server ($attempt/$total)…',
|
||||
@@ -259,9 +264,11 @@ class VpnConnection extends ChangeNotifier {
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
debugPrint('[VPN] Config fetch error: $e');
|
||||
final raw = e.toString().replaceAll('Exception: ', '');
|
||||
if (raw.contains('Cancelled')) return false;
|
||||
_suppressNativeConnect = true;
|
||||
if (raw.contains('timed out') || raw.contains('Session not activated')) {
|
||||
_errorMessage =
|
||||
'The server did not respond in time.\nCheck your connection and try again.';
|
||||
@@ -273,11 +280,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_cancelled) {
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_setStatus(VpnStatus.connecting, 'Starting secure route…');
|
||||
|
||||
try {
|
||||
@@ -285,6 +288,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
final baseConfig = parsed.getFullConfiguration();
|
||||
final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
|
||||
final secureSocks = await SecureSocksSession.create(baseConfig);
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
final verifyLocalSocks = _activeMode == ConnectionMode.proxy;
|
||||
|
||||
await _startRuntime(
|
||||
@@ -292,26 +296,45 @@ class VpnConnection extends ChangeNotifier {
|
||||
remark: remark,
|
||||
proxyOnly: verifyLocalSocks,
|
||||
);
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
|
||||
_setStatus(
|
||||
VpnStatus.connecting,
|
||||
verifyLocalSocks ? 'Waiting for local SOCKS5…' : 'Waiting for VPN interface…',
|
||||
verifyLocalSocks
|
||||
? 'Waiting for local SOCKS5…'
|
||||
: 'Waiting for VPN interface…',
|
||||
);
|
||||
if (!await _waitForNativeConnected()) {
|
||||
if (!await _waitForNativeConnected(connectEpoch)) {
|
||||
throw StateError('VPN runtime did not report CONNECTED');
|
||||
}
|
||||
|
||||
if (verifyLocalSocks) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…');
|
||||
if (!await _waitForLocalSocksListener(secureSocks)) {
|
||||
if (!await _waitForLocalSocksListener(
|
||||
secureSocks,
|
||||
connectEpoch,
|
||||
)) {
|
||||
throw StateError('Local SOCKS5 listener did not become ready');
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_lastSecureSocks = secureSocks;
|
||||
} catch (e) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
debugPrint('[VPN] Tunnel start error: $e');
|
||||
await _stopRuntime();
|
||||
_suppressNativeConnect = true;
|
||||
try {
|
||||
await _stopRuntime();
|
||||
} catch (stopError) {
|
||||
debugPrint('[VPN] Runtime cleanup failed after start error: $stopError');
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_errorMessage = 'VPN failed to shut down cleanly.\nPlease restart the app.';
|
||||
_setStatus(VpnStatus.error, 'Shutdown failed');
|
||||
return false;
|
||||
}
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_clearRuntimeSnapshot();
|
||||
_errorMessage = _activeMode == ConnectionMode.proxy
|
||||
? 'SOCKS5 gateway failed to start.\nTry reconnecting.'
|
||||
@@ -320,6 +343,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
return true;
|
||||
}
|
||||
@@ -336,22 +360,20 @@ class VpnConnection extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _waitForLocalSocksListener([
|
||||
SecureSocksSession? session,
|
||||
]) async {
|
||||
final active = session ?? _lastSecureSocks;
|
||||
if (active == null) return false;
|
||||
|
||||
Future<bool> _waitForLocalSocksListener(
|
||||
SecureSocksSession session,
|
||||
int connectEpoch,
|
||||
) async {
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
final result = await LocalSocksTester.testListener(
|
||||
host: '127.0.0.1',
|
||||
port: active.port,
|
||||
username: active.username,
|
||||
password: active.password,
|
||||
port: session.port,
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
);
|
||||
if (result.ok) {
|
||||
return true;
|
||||
}
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
if (result.ok) return true;
|
||||
if (attempt < 3) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
@@ -359,54 +381,59 @@ class VpnConnection extends ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _waitForNativeConnected() async {
|
||||
// startVless() queues the Android foreground service. Wait for the native
|
||||
// service/core broadcast rather than treating MethodChannel completion as
|
||||
// proof that a TUN interface was established.
|
||||
Future<bool> _waitForNativeConnected(int connectEpoch) async {
|
||||
for (var attempt = 0; attempt < 24; attempt++) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
if (_status == VpnStatus.connected) return true;
|
||||
if (_status == VpnStatus.error) return false;
|
||||
if (_cancelled || _userDisconnecting) return false;
|
||||
if (_status == VpnStatus.error || _status == VpnStatus.disconnected) {
|
||||
return false;
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
return _status == VpnStatus.connected;
|
||||
return _isCurrentConnect(connectEpoch) &&
|
||||
_status == VpnStatus.connected;
|
||||
}
|
||||
|
||||
Future<void> _stopRuntime() async {
|
||||
try {
|
||||
await _vless.stopVless().timeout(const Duration(seconds: 5));
|
||||
} catch (_) {}
|
||||
await _vless.stopVless().timeout(const Duration(seconds: 8));
|
||||
}
|
||||
|
||||
Future<void> setNativeSessionDeadline(int remainingSeconds) async {
|
||||
if (kIsWeb) return;
|
||||
if (_disposed) throw StateError('VPN connection has been disposed');
|
||||
if (!_initialized) throw StateError('VPN native service is not initialized');
|
||||
if (remainingSeconds < 0) {
|
||||
throw ArgumentError.value(remainingSeconds, 'remainingSeconds');
|
||||
}
|
||||
await _nativeControl.invokeMethod<void>(
|
||||
'setSessionDeadline',
|
||||
<String, Object>{'remainingSeconds': remainingSeconds},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_cancelled = true;
|
||||
if (_disposed || _status == VpnStatus.disconnecting) return;
|
||||
|
||||
_connectEpoch++;
|
||||
_suppressNativeConnect = true;
|
||||
_userDisconnecting = true;
|
||||
HivemindService.cancel();
|
||||
|
||||
if (_status == VpnStatus.disconnected ||
|
||||
_status == VpnStatus.disconnecting) {
|
||||
_clearRuntimeSnapshot();
|
||||
_userDisconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
|
||||
if (kIsWeb) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (_disposed) return;
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
_userDisconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
bool timedOut = false;
|
||||
try {
|
||||
await _vless.stopVless().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => timedOut = true,
|
||||
);
|
||||
await _vless.stopVless().timeout(const Duration(seconds: 8));
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
debugPrint('[VPN] VLESS stop error: $e');
|
||||
_errorMessage = 'VPN shutdown error.\nPlease restart the app.';
|
||||
_clearRuntimeSnapshot();
|
||||
@@ -415,15 +442,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
debugPrint('[VPN] stopVless() timed out after 5 s.');
|
||||
_errorMessage = 'VPN did not shut down cleanly.\nPlease restart the app.';
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.error, 'Shutdown failed');
|
||||
_userDisconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_disposed) return;
|
||||
_errorMessage = null;
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
@@ -436,21 +455,27 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _setStatus(VpnStatus status, String message) {
|
||||
if (_disposed) return;
|
||||
_status = status;
|
||||
_statusMessage = message;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _checkHealth() async {
|
||||
// A live tunnel already proves reachability; only probe while disconnected
|
||||
// so we don't add direct /health traffic while the VPN is active.
|
||||
if (_status == VpnStatus.connected) return;
|
||||
_serverReachable = await HivemindService.checkHealth();
|
||||
if (_disposed || _status == VpnStatus.connected) return;
|
||||
final reachable = await HivemindService.checkHealth();
|
||||
if (_disposed || _status == VpnStatus.connected) return;
|
||||
_serverReachable = reachable;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_connectEpoch++;
|
||||
_suppressNativeConnect = true;
|
||||
_userDisconnecting = true;
|
||||
HivemindService.cancel();
|
||||
_healthTimer?.cancel();
|
||||
_networkSubscription?.cancel();
|
||||
// Provider/UI disposal is not a user disconnect command. Keeping teardown
|
||||
|
||||
Reference in new issue
Block a user