From e67f0b93eeff08b9f184d764519b3c76713e917b Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 17 Oct 2026 16:31:51 +0300 Subject: [PATCH] feat(session): hand the session back when the user disconnects The client never called /session/stop - the string appears nowhere in lib/ or local_packages/, on this branch or on main - while the server has implemented the route all along (hivemind/api.py:237) and AGENTS.md rule 33 documents its ordering. Across a full device test session, two user disconnects and several expiries, the control plane logged zero calls. So a disconnect left the VLESS client provisioned in the Reality inbound and the session dict in memory and sessions.json until its window ran out. Anyone holding that UUID could keep tunnelling for the remainder. Users were not locked out of their quota - _handle_main replaces the session with a fresh full-length one on the next connect - so this is hygiene rather than a user-facing bug. Only SessionTimer.disconnect() calls it, which is reached only from the connect button. Expiry, the hard cap and a server-ended session are already gone server-side, and the route takes nothing but a device_id as proof of ownership, so no error path may reach it. Unawaited and capped at 3 s, so an unreachable API cannot delay the teardown, and every failure is swallowed: the session expires on its own regardless. Verified against a mock control plane: user disconnect POST /api/session/stop -> session removed expiry no call; alert still posted Co-Authored-By: Claude Opus 5 --- lib/logic/hivemind_service.dart | 65 +++++++++++++++++++++++++++++++++ lib/logic/session_timer.dart | 4 ++ 2 files changed, 69 insertions(+) diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index 007bd71..81111df 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -76,6 +76,71 @@ class HivemindService { } } + static Future controlPost( + Uri uri, + Map body, { + Duration timeout = const Duration(seconds: 5), + }) async { + final client = http.Client(); + final request = http.Request('POST', uri) + ..headers['User-Agent'] = _ua + ..headers['Content-Type'] = 'application/json' + ..body = jsonEncode(body) + ..followRedirects = false; + + Future send() async { + final streamed = await client.send(request); + final declaredLength = streamed.contentLength; + if (declaredLength != null && declaredLength > _maxControlResponseBytes) { + throw const FormatException('Control response is too large.'); + } + + final bytes = []; + await for (final chunk in streamed.stream) { + if (bytes.length + chunk.length > _maxControlResponseBytes) { + throw const FormatException('Control response is too large.'); + } + bytes.addAll(chunk); + } + + return http.Response.bytes(bytes, streamed.statusCode, + headers: streamed.headers, request: request); + } + + try { + return await send().timeout( + timeout, + onTimeout: () { + client.close(); + throw TimeoutException('Control request timed out.', timeout); + }, + ); + } finally { + client.close(); + } + } + + // Hands the session back so the server can drop the VLESS client instead of + // leaving it provisioned until the window runs out. + // + // Only the user-initiated disconnect calls this. An expiry, a hard cap or a + // server-ended session is already gone server-side, and /session/stop takes + // nothing but a device_id as proof (hivemind/api.py notes this), so a + // transient client error must never be allowed to destroy a live session. + // Best effort throughout: the session expires on its own regardless. + static Future releaseSession() async { + try { + final deviceId = await CryptoService.getDeviceId(); + await controlPost( + _publicUrl('/session/stop'), + {'device_id': deviceId}, + timeout: const Duration(seconds: 3), + ); + } catch (_) { + // Nothing to recover: the server expires the session anyway. + } + } + static void cancel() { _currentCallId++; _expectedNonce = null; diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index d55ee1c..44e54e7 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -241,6 +241,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } Future 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. + unawaited(HivemindService.releaseSession()); await _doDisconnect(reason); }