feat(session): every reconnect starts a fresh session

SOCKS5 Reconnect re-attached to the session the device already held. It
was the only path where a session began without an ad and without a
nonce binding it to one, and supporting it cost three parameters -
resume, release, resetSupportClaim - plus a carve-out in rule 4 that let
_fetchActiveSession accept whatever nonce the server happened to return.

It also wasn't the kindness it looked like. _handle_main calls
create_or_replace with a fresh full window, so a new session hands the
user *more* time, not less; re-attaching only preserved the remainder of
a session whose proxy had just died.

Reconnect now disconnects and connects again exactly as the button does,
ad included. All four special cases are deleted and the nonce check is
unconditional.

Costs one rewarded ad to repair an orphaned proxy once ads are enabled.
esef's call, and it buys a single way to start a session.

Also fixes the 3.3.7 regression where Reconnect released the session and
then asked for it back: there is no longer a path that does either.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
esefxdzandClaude Opus 5 committed 2026-10-17 18:53:42 +03:00
1 parent 73d76c04f2
commit 3ec11a383d
7 files changed
+71 -47

No files matched your search

+16 -1
View File
@@ -5,9 +5,24 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
---
## [3.3.8] — 2026-10-17
### Changed
- **Reconnecting always starts a fresh session.** In SOCKS5 mode, the
**Reconnect** button used to re-attach to the session you already had. It now
ends that session and starts a new one, exactly as the connect button does —
one way to begin a session instead of two, and you get a full window back
rather than the remainder of a broken one.
### Fixed
- **SOCKS5 "Reconnect" works again.** In 3.3.7 it ended your session and then
asked for the session it had just ended, failing with "Session not activated".
---
## [3.3.7] — 2026-10-17
> **Known issue — fixed in the next release.** In SOCKS5 mode, the **Reconnect**
> **Known issue — fixed in 3.3.8.** In SOCKS5 mode, the **Reconnect**
> button in Settings ends your session instead of re-attaching to it, and then
> fails with "Session not activated". It only appears when a proxy session has
> been orphaned, and you lose the rest of that session rather than anything
+9 -13
View File
@@ -146,14 +146,13 @@ class HivemindService {
_expectedNonce = null;
}
// Fetches this device's VLESS URL.
// adNonce — wait for the session that ad created (SSV can lag, so poll longer)
// resume — re-attach to the session already held; any nonce accepted
// neither — ads off: mint a nonce and fire the bypass (rule 87)
// Fetches this device's VLESS URL. Every session starts the same way, so
// there is always a nonce to bind it to: either the one the ad minted, or one
// we mint here before firing the bypass (rule 87). The re-attach path that
// used to accept any nonce is gone - see rule 4.
static Future<String> fetchConfigDirectly({
void Function(int attempt, int total)? onAttempt,
String? adNonce,
bool resume = false,
}) async {
// Capture the generation before the first await. Otherwise a disconnect
// that lands while secure storage is resolving the device ID can be lost
@@ -162,11 +161,11 @@ class HivemindService {
final deviceId = await CryptoService.getDeviceId();
_throwIfCancelled(callId);
final nonce = resume ? null : (adNonce ?? newNonce());
final nonce = adNonce ?? newNonce();
_expectedNonce = nonce;
if (!resume && adNonce == null) {
await _runConfiguredBypass(deviceId, nonce!);
if (adNonce == null) {
await _runConfiguredBypass(deviceId, nonce);
_throwIfCancelled(callId);
}
@@ -229,10 +228,9 @@ class HivemindService {
}
}
// nonce == null accepts whatever nonce the server holds (resume path).
static Future<_HivemindSessionConfig?> _fetchActiveSession(
String deviceId,
String? nonce,
String nonce,
) async {
final statusUri = _publicUrl('/session/status').replace(
queryParameters: {'device_id': deviceId},
@@ -245,9 +243,7 @@ class HivemindService {
final serverNonce = decoded['nonce'];
if (_expectedNonce != nonce) return null;
if (nonce != null && (serverNonce is! String || serverNonce != nonce)) {
return null;
}
if (serverNonce is! String || serverNonce != nonce) return null;
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
return null;
+4 -13
View File
@@ -158,9 +158,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
}
// resetSupportClaim is false when re-attaching to a session already held:
// the top-up is once per server session and this flag is what enforces it.
Future<void> start({bool resetSupportClaim = true}) async {
Future<void> start() async {
_sessionEpoch++;
_remainingSeconds = 0;
_remainingAtLastSync = 0;
@@ -178,12 +176,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_lastEndMessage = null;
NotificationService.reset();
if (resetSupportClaim) {
// Every session is new, so the once-per-session top-up resets with it.
_supportStateEpoch++;
_supportRewardClaimed = false;
_supportRewardStateLoaded = true;
unawaited(_persistSupportRewardState(false));
}
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
@@ -240,17 +237,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
}
// release is false when the caller intends to re-attach to the same session,
// as the SOCKS5 Reconnect does: handing it back here would destroy the very
// session the follow-up connect(resume: true) is about to ask for.
Future<void> disconnect({
String reason = 'User requested',
bool release = true,
}) async {
Future<void> disconnect({String reason = 'User requested'}) async {
// Only here, not in _doDisconnect: this is the one path the user actually
// asked for. Unawaited so a slow or unreachable API never holds up the
// teardown.
if (release) unawaited(HivemindService.releaseSession());
unawaited(HivemindService.releaseSession());
await _doDisconnect(reason);
}
+1 -2
View File
@@ -200,7 +200,7 @@ class VpnConnection extends ChangeNotifier {
}
}
Future<bool> connect({String? adNonce, bool resume = false}) async {
Future<bool> connect({String? adNonce}) async {
if (_disposed ||
_userDisconnecting ||
_status == VpnStatus.connected ||
@@ -266,7 +266,6 @@ class VpnConnection extends ChangeNotifier {
try {
realUrl = await HivemindService.fetchConfigDirectly(
adNonce: adNonce,
resume: resume,
onAttempt: (attempt, total) {
if (!_isCurrentConnect(connectEpoch)) return;
_setStatus(
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/ad_manager.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/local_socks_tester.dart';
@@ -67,20 +68,27 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
widget.onChanged?.call(next);
}
// Recovers an orphaned proxy: tear the runtime down and re-attach to the
// session already held. No ad, no new session.
// Recovers an orphaned proxy by ending the session and starting a new one,
// exactly as the connect button does. Re-attaching to the session already
// held was the one place a session began without an ad and without a nonce
// to bind it to; a fresh one is the same path everywhere and hands back a
// full window rather than the remainder of a broken one.
Future<void> _reconnect() async {
if (_reconnecting) return;
final vpn = context.read<VpnConnection>();
final timer = context.read<SessionTimer>();
final ad = context.read<AdManager>();
setState(() => _reconnecting = true);
try {
await timer.disconnect(release: false);
await timer.disconnect();
if (!mounted) return;
if (await vpn.connect(resume: true)) {
final adNonce = await ad.showAd('main');
if (adNonce == null && AdManager.adsEnabled) return;
if (!mounted) return;
await timer.start(resetSupportClaim: false);
if (await vpn.connect(adNonce: adNonce)) {
if (!mounted) return;
await timer.start();
}
} finally {
if (mounted) {
+1 -1
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 3.3.7+33
version: 3.3.8+34
environment:
sdk: ">=3.0.0 <4.0.0"
+27 -12
View File
@@ -116,7 +116,7 @@ void main() {
// /session/stop takes a device_id as its only proof of ownership, so it
// must never be reachable from an error path. disconnect() is called only
// by the connect button; _doDisconnect is every other ending.
final disconnectEntry = timer.indexOf('Future<void> disconnect(');
final disconnectEntry = timer.indexOf('Future<void> disconnect({');
final doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
final release = timer.indexOf('HivemindService.releaseSession()');
expect(release, greaterThan(disconnectEntry));
@@ -132,21 +132,36 @@ void main() {
);
});
test('SOCKS5 Reconnect re-attaches instead of handing the session back', () {
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
test('every session starts the same way - no re-attach path', () {
final mode =
File('lib/screens/settings/in_settings/connection_mode.dart')
.readAsStringSync();
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
// Reconnect repairs an orphaned proxy by re-attaching to the session the
// device already holds. Releasing it on the way out would destroy the very
// session the follow-up connect(resume: true) then asks for.
expect(timer, contains('bool release = true'));
expect(timer, contains('if (release) unawaited(HivemindService.releaseSession())'));
expect(mode, contains('timer.disconnect(release: false)'));
expect(mode, contains('vpn.connect(resume: true)'));
// SOCKS5 Reconnect used to re-attach to the session already held. It was
// the only place a session began without an ad and without a nonce binding
// it, and it needed three parameters and a carve-out in rule 4 to support.
// Reconnect now ends the session and starts a fresh one, like the button.
expect(mode, contains("ad.showAd('main')"));
expect(mode, contains('vpn.connect(adNonce: adNonce)'));
expect(mode, isNot(contains('resume: true')));
expect(mode, isNot(contains('release: false')));
expect(mode, isNot(contains('resetSupportClaim')));
// And it must not hand back a second support top-up for the same session.
expect(mode, contains('timer.start(resetSupportClaim: false)'));
// The special cases that only existed for it are gone.
expect(hivemind, isNot(contains('bool resume')));
expect(vpn, isNot(contains('bool resume')));
expect(timer, isNot(contains('bool release')));
expect(timer, isNot(contains('resetSupportClaim')));
// So the nonce is now unconditional: no path accepts whatever the server
// happens to hold.
expect(
hivemind,
contains('if (serverNonce is! String || serverNonce != nonce) return null;'),
reason: 'the nonce check must not be skippable',
);
});
}