feat(client): 弱网抖动先自动重连当前节点,不再动不动报「节点异常」(#18)
弱网下(实测 RTT 2.2s/丢包)urltest 瞬断或内核 off,看门狗此前一判死就断开+要用户手动 重连(nodeUnhealthyError),体验差。改为:无备用节点时先自动重连**当前节点**(不换节点—— 弱网是本地网络问题,换节点无益还会来回横跳),连续失败超过 _kMaxAutoReconnect(3) 次才真报 「节点异常」。urltest 成功或用户主动连接即清零计数,只有持续失败才耗尽。 - _tryAutoReconnectCurrent(bounded 重连当前节点)+ _handleUnexpectedOff(kernel off 走同一逻辑)。 - _onNodeUnhealthy 无备用分支改调 _tryAutoReconnectCurrent;智能有备用仍优先切备用(不变)。 - 重连期给「网络波动,正在重连…」瞬态提示(nodeReconnecting,zh/en)。 - 测试:注入时钟 + 内存 SecureKV(避免 deviceId 挂起);kernel-off 连续判死→3 次自动重连后报 节点异常;路径B 无备用→自动重连当前节点不换节点。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,8 @@ const _kUrltestStale = Duration(seconds: 45);
|
||||
// stats 帧在此时长内到过即视为「在流」。超过(app 挂起/唤醒未恢复)时看门狗路径 A 不判活,
|
||||
// 避免把 app 被挂起的空档错算成节点死。须 > 原生 stats 轮询间隔(~1s),留足余量。
|
||||
const _kStatsLive = Duration(seconds: 10);
|
||||
// 无备用节点时,弱网抖动判「节点异常」先自动重连当前节点的最大次数;超过才真报错。#18
|
||||
const _kMaxAutoReconnect = 3;
|
||||
|
||||
// ── 状态机 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -156,6 +158,9 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
ConnectApi? _api;
|
||||
// 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。
|
||||
Node? _connectedNode;
|
||||
// 无备用节点时「先自动重连当前节点」的连续尝试计数;连接恢复健康(urltest 成功)或
|
||||
// 用户主动连接时清零,只有持续失败才耗尽额度后真报「节点异常」。#18
|
||||
int _autoReconnectAttempts = 0;
|
||||
// 免费版:连接时锁定的剩余额度(秒);连接期按「_freeRemainingSec − 已用秒」倒计时,
|
||||
// 归零即自动切断(_onFreeQuotaExhausted)。null = 会员/额度不限,不倒计时。
|
||||
int? _freeRemainingSec;
|
||||
@@ -166,6 +171,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
void toggle() {
|
||||
switch (state.phase) {
|
||||
case VpnPhase.off:
|
||||
_autoReconnectAttempts = 0; // 用户主动连接:重置弱网自动重连额度(#18)
|
||||
_connect();
|
||||
case VpnPhase.on:
|
||||
_userDisconnect = true; // 用户主动断开:其 kernel off 不当作节点异常
|
||||
@@ -312,15 +318,19 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
// 必须排除 connecting 握手期的瞬态 off——macOS NE 连接序列是 off→connecting→off→
|
||||
// connecting→on,connecting 期的 off 是握手抖动、不是异常(否则一连接就误报「节点异常」)。
|
||||
final wasConnected = state.phase == VpnPhase.on;
|
||||
_stopElapsed();
|
||||
_stopWatchdog();
|
||||
if (!_userDisconnect && wasConnected && _offNotice == null) {
|
||||
_offNotice = _ref.read(appTextProvider).nodeUnhealthyError;
|
||||
// 意外掉线(非用户主动、曾连上;弱网抖动最常见的就是这条 kernel off)。先自动重连
|
||||
// **当前节点**(不换节点——弱网是本地网络问题,换节点无益还会来回横跳),重试用尽才
|
||||
// 报「节点异常」。(wasConnected 闸:重连握手期再掉线时 phase 已非 on,不自我循环触发。)#18
|
||||
logLine('Watchdog', 'unexpected kernel ${s.name} after connected → node interrupted');
|
||||
unawaited(_ref.read(nodesProvider.notifier).refresh());
|
||||
_userDisconnect = false;
|
||||
unawaited(_handleUnexpectedOff());
|
||||
return;
|
||||
}
|
||||
state = ConnectionState(phase: VpnPhase.off, error: _offNotice);
|
||||
_userDisconnect = false;
|
||||
_stopElapsed();
|
||||
_stopWatchdog();
|
||||
_refreshQuota(); // 内核掉线也算会话结束 → 刷新「今日剩余」
|
||||
}
|
||||
}
|
||||
@@ -344,6 +354,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
if (ds.isEmpty) return;
|
||||
// urltest 成功:经 proxy 出站(REALITY 到节点)真实可达 → 记录时刻(供路径 A 判活)+ 回写延迟。
|
||||
_lastUrltestOk = now;
|
||||
_autoReconnectAttempts = 0; // 节点已恢复健康 → 清零自动重连计数,下次抖动重获满额重试。#18
|
||||
final best = ds.reduce((a, b) => a < b ? a : b);
|
||||
_ref.read(nodesProvider.notifier).setLivePing(node.uuid, best);
|
||||
}
|
||||
@@ -414,12 +425,38 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 手动选定节点(或智能模式无其他可用节点):断开并提示,尊重用户选择、不自动换。
|
||||
// 提示走 _offNotice,由 _disconnect/_onKernelStatus 应用,避免被 kernel off 覆盖。
|
||||
// 无备用节点(手动选定节点 / 智能但无其他可用):弱网抖动别一判死就断开+要用户手动重连。
|
||||
// 先自动重连**当前节点**,连续失败超过上限再真报「节点异常」。#18
|
||||
if (await _tryAutoReconnectCurrent()) return;
|
||||
// 重试用尽:断开并提示「节点异常」。提示走 _offNotice,由 _disconnect/_onKernelStatus 应用。
|
||||
_offNotice = t.nodeUnhealthyError;
|
||||
await _disconnect();
|
||||
}
|
||||
|
||||
/// 弱网抖动:自动重连**当前节点**(不换节点、不改 selectedNode),bounded。
|
||||
/// 返回 true = 已发起重连(额度内);false = 已用尽额度,调用方应改报「节点异常」。#18
|
||||
/// urltest 成功(_onStats)或用户主动连接(toggle)会把计数清零 → 只有持续失败才耗尽。
|
||||
Future<bool> _tryAutoReconnectCurrent() async {
|
||||
if (_autoReconnectAttempts >= _kMaxAutoReconnect) return false;
|
||||
_autoReconnectAttempts++;
|
||||
final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider);
|
||||
logLine('Watchdog', 'auto-reconnect current node ${node.code} (attempt $_autoReconnectAttempts/$_kMaxAutoReconnect)');
|
||||
await _disconnect();
|
||||
await _connect();
|
||||
// 重连握手中给「网络波动,正在重连…」瞬态提示(连上后随 copyWith 清除)。
|
||||
if (mounted && state.phase != VpnPhase.off) {
|
||||
state = ConnectionState(phase: state.phase, error: _ref.read(appTextProvider).nodeReconnecting);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 意外内核掉线(弱网)的处理:先自动重连当前节点,额度用尽才断开并报「节点异常」。#18
|
||||
Future<void> _handleUnexpectedOff() async {
|
||||
if (await _tryAutoReconnectCurrent()) return;
|
||||
_offNotice = _ref.read(appTextProvider).nodeUnhealthyError;
|
||||
await _disconnect();
|
||||
}
|
||||
|
||||
/// 选延迟最优、可用(status up)、非当前节点的 code;无则 null。
|
||||
String? _pickAlternativeCode(String excludeCode) {
|
||||
final nodes = (_ref.read(nodesProvider).valueOrNull ?? const [])
|
||||
|
||||
Reference in New Issue
Block a user