fix(app): stop probing the control plane while disconnected
Android CI / android (push) Canceled after 0s

VpnConnection._init() started a 30 s /health poll from the constructor
with no lifecycle gating, so it ran for the life of the app process -
backgrounded, and whether or not the user ever connected. The app is
excluded from its own tunnel, so those requests left in the clear. A
periodic request that fires while the user is deliberately not connected
is an avoidable fingerprint, and rule 60 already ruled probes out:
reachability is meant to be passive.

All it bought was the status dot before the first connect, and it got
that wrong in both directions - "Offline" on a transient resolver
failure against a server that was fine.

Reachability now comes from a session sync that succeeded. serverHealthy
is isConnected && hasSyncedOnce, so a green dot means a working session
rather than a check made beforehand; while disconnected the bar reads
"Ready" and claims nothing either way.

checkHealth() goes with it. Nothing calls it, and leaving a probe helper
in place invites its reuse.

Pinned in test/session_lifecycle_regression_test.dart so it cannot come
back as a tidy-up. flutter analyze clean, 10/10 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
esefxdzandClaude Opus 5 committed 2026-10-17 21:17:17 +03:00
1 parent 571c07bca8
commit 62e968f133
5 files changed
+39 -37

No files matched your search

+13
View File
@@ -5,6 +5,19 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
--- ---
## [Unreleased]
### Removed
- **The background server check while disconnected.** The app no longer contacts
the server every 30 seconds while you are not connected to it. Nothing about
the connected session changes.
### Changed
- **The status bar no longer guesses whether the server is up.** It reads
"Ready" while disconnected and shows "Online" once a session has synced, so a
green dot now means a session that is actually working rather than a
reachability check made beforehand.
## [3.3.8] — 2026-10-17 ## [3.3.8] — 2026-10-17
### Changed ### Changed
+6 -7
View File
@@ -49,15 +49,14 @@ class StatusBar extends StatelessWidget {
builder: (context, vpn, timer, _) { builder: (context, vpn, timer, _) {
final isConnected = vpn.status == VpnStatus.connected; final isConnected = vpn.status == VpnStatus.connected;
final isConnecting = vpn.status == VpnStatus.connecting; final isConnecting = vpn.status == VpnStatus.connecting;
final serverHealthy = // Rule 60: reachability is known only from a session sync that
vpn.serverReachable || (isConnected && timer.hasSyncedOnce); // succeeded, never from a probe. While disconnected the app knows
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable; // nothing about the server, so it claims nothing either way.
final serverHealthy = isConnected && timer.hasSyncedOnce;
final statusLabel = isConnected final statusLabel = isConnected
? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…') ? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
: (isConnecting : (isConnecting ? 'Connecting…' : 'Ready');
? 'Connecting…'
: (vpn.serverReachable ? 'Ready' : 'Offline'));
final detail = _detail(vpn, timer); final detail = _detail(vpn, timer);
-12
View File
@@ -197,18 +197,6 @@ class HivemindService {
throw Exception('Session not activated. Server callback may have timed out.'); throw Exception('Session not activated. Server callback may have timed out.');
} }
static Future<bool> checkHealth() async {
try {
final response = await controlGet(
_publicUrl('/health'),
timeout: const Duration(seconds: 3),
);
return response.statusCode == 200;
} catch (_) {
return false;
}
}
static String newNonce() { static String newNonce() {
final random = Random.secure(); final random = Random.secure();
final high = random.nextInt(0x7FFFFFFF); final high = random.nextInt(0x7FFFFFFF);
-18
View File
@@ -38,9 +38,6 @@ class VpnConnection extends ChangeNotifier {
bool _adoptedRunningRuntime = false; bool _adoptedRunningRuntime = false;
bool get adoptedRunningRuntime => _adoptedRunningRuntime; bool get adoptedRunningRuntime => _adoptedRunningRuntime;
bool _serverReachable = false;
bool get serverReachable => _serverReachable;
ConnectionMode _activeMode = ConnectionMode.tun; ConnectionMode _activeMode = ConnectionMode.tun;
ConnectionMode get activeMode => _activeMode; ConnectionMode get activeMode => _activeMode;
@@ -73,7 +70,6 @@ class VpnConnection extends ChangeNotifier {
); );
} }
Timer? _healthTimer;
StreamSubscription<NetworkSnapshot>? _networkSubscription; StreamSubscription<NetworkSnapshot>? _networkSubscription;
SecureSocksSession? _lastSecureSocks; SecureSocksSession? _lastSecureSocks;
@@ -142,11 +138,6 @@ class VpnConnection extends ChangeNotifier {
}, },
); );
unawaited(_checkHealth());
_healthTimer = Timer.periodic(const Duration(seconds: 30), (_) {
unawaited(_checkHealth());
});
try { try {
final coreVersion = await _vless.getCoreVersion(); final coreVersion = await _vless.getCoreVersion();
if (!_disposed) { if (!_disposed) {
@@ -472,14 +463,6 @@ class VpnConnection extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> _checkHealth() async {
if (_disposed || _status == VpnStatus.connected) return;
final reachable = await HivemindService.checkHealth();
if (_disposed || _status == VpnStatus.connected) return;
_serverReachable = reachable;
notifyListeners();
}
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
@@ -487,7 +470,6 @@ class VpnConnection extends ChangeNotifier {
_suppressNativeConnect = true; _suppressNativeConnect = true;
_userDisconnecting = true; _userDisconnecting = true;
HivemindService.cancel(); HivemindService.cancel();
_healthTimer?.cancel();
_networkSubscription?.cancel(); _networkSubscription?.cancel();
// Provider/UI disposal is not a user disconnect command. Keeping teardown // Provider/UI disposal is not a user disconnect command. Keeping teardown
// in disconnect() prevents lifecycle churn from silently dropping the VPN. // in disconnect() prevents lifecycle churn from silently dropping the VPN.
@@ -164,4 +164,24 @@ void main() {
reason: 'the nonce check must not be skippable', reason: 'the nonce check must not be skippable',
); );
}); });
test('nothing probes the control plane while disconnected - rule 60', () {
final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
final bar = File('lib/components/status_bar.dart').readAsStringSync();
// The runtime excludes the app from its own tunnel
// (XrayVPNService.kt addDisallowedApplication), so a periodic /health probe
// was a cleartext-SNI metronome to the API host - which resolves to the
// tunnel IP. It fed step 1 of the kill chain in KNOWN_SECURITY_ISSUES #3,
// in exchange for a green dot before the user had connected to anything.
expect(vpn, isNot(contains('_healthTimer')));
expect(vpn, isNot(contains('_checkHealth')));
expect(hivemind, isNot(contains('checkHealth')));
expect(hivemind, isNot(contains("'/health'")));
// Reachability is passive now: a session sync that succeeded, nothing else.
expect(bar, isNot(contains('serverReachable')));
expect(bar, contains('isConnected && timer.hasSyncedOnce'));
});
} }