From e751a9d6e4c2d43bbc7848cb8f3e9071cb4782e9 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 17 Oct 2026 17:17:29 +0300 Subject: [PATCH] fix(session): don't release the session on a SOCKS5 Reconnect Regression from e67f0b9, shipped in 3.3.7. Reconnect repairs an orphaned proxy by tearing the runtime down and re-attaching to the session the device already holds. It does that through SessionTimer.disconnect(), which e67f0b9 taught to hand the session back to the server - so it destroyed the very session the following connect(resume: true) then asked for. Unawaited, so it raced: the resume either found the session already gone or removed moments later, surfacing as "Session not activated". disconnect() now takes release, and the Reconnect path passes false. Every other caller still hands the session back. Narrow in practice: SOCKS5 mode only, only the Settings Reconnect button, and the main-screen connect was unaffected because it mints a new session anyway. Co-Authored-By: Claude Opus 5 --- lib/logic/session_timer.dart | 10 ++++++++-- .../settings/in_settings/connection_mode.dart | 2 +- test/session_lifecycle_regression_test.dart | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 44e54e7..0b99cc1 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -240,11 +240,17 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } } - Future disconnect({String reason = 'User requested'}) async { + // 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 disconnect({ + String reason = 'User requested', + bool release = true, + }) 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. - unawaited(HivemindService.releaseSession()); + if (release) unawaited(HivemindService.releaseSession()); await _doDisconnect(reason); } diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart index 0f0fe32..e91f3ad 100644 --- a/lib/screens/settings/in_settings/connection_mode.dart +++ b/lib/screens/settings/in_settings/connection_mode.dart @@ -76,7 +76,7 @@ class _ConnectionModeTileState extends State { setState(() => _reconnecting = true); try { - await timer.disconnect(); + await timer.disconnect(release: false); if (!mounted) return; if (await vpn.connect(resume: true)) { if (!mounted) return; diff --git a/test/session_lifecycle_regression_test.dart b/test/session_lifecycle_regression_test.dart index e282042..3819ced 100644 --- a/test/session_lifecycle_regression_test.dart +++ b/test/session_lifecycle_regression_test.dart @@ -131,4 +131,22 @@ void main() { reason: 'exactly one call site', ); }); + + test('SOCKS5 Reconnect re-attaches instead of handing the session back', () { + final timer = File('lib/logic/session_timer.dart').readAsStringSync(); + final mode = + File('lib/screens/settings/in_settings/connection_mode.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)')); + + // And it must not hand back a second support top-up for the same session. + expect(mode, contains('timer.start(resetSupportClaim: false)')); + }); }