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:
wangjia
2026-07-02 09:57:19 +08:00
parent daaed39bac
commit be27529054
5 changed files with 101 additions and 20 deletions
+1
View File
@@ -71,6 +71,7 @@ abstract class AppText {
// 连通看门狗:当前节点数据面不可用时的提示
String get nodeUnhealthySwitched; // 智能选择已自动切换
String get nodeUnhealthyError; // 手动节点:断开并提示
String get nodeReconnecting; // 弱网抖动:自动重连当前节点中(#18)
String get smartSub;
String get recommended;
+2
View File
@@ -96,6 +96,8 @@ class StringsEn extends AppText {
@override
String get nodeUnhealthyError => 'Current node is unreachable — reconnect or switch node';
@override
String get nodeReconnecting => 'Network unstable, reconnecting…';
@override
String get smartSub => 'Picks the best node for your network';
@override
String get recommended => 'Recommended';
+2
View File
@@ -95,6 +95,8 @@ class StringsZh extends AppText {
@override
String get nodeUnhealthyError => '当前节点异常,请重连或更换节点';
@override
String get nodeReconnecting => '网络波动,正在重连…';
@override
String get smartSub => '根据当前网络环境,自动选择最优节点';
@override
String get recommended => '推荐';
+43 -6
View File
@@ -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 [])
+53 -14
View File
@@ -11,6 +11,7 @@ import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
import 'package:pangolin_vpn/l10n/strings_zh.dart';
import 'package:pangolin_vpn/models/node.dart';
import 'package:pangolin_vpn/services/connect_api.dart';
import 'package:pangolin_vpn/services/device_identity.dart';
import 'package:pangolin_vpn/state/connection_provider.dart';
import 'package:pangolin_vpn/state/nodes_provider.dart';
import 'package:pangolin_vpn/state/quota_provider.dart';
@@ -55,6 +56,16 @@ class _FakeConnectApi extends ConnectApi {
void dispose() {}
}
// 内存 SecureKV:避免测试里 flutter_secure_storage 平台通道无 handler 时 deviceId() 挂起
// (进而 _connect 卡在 connecting)。#18 自动重连会走 _connect,必须让 deviceId() 立即返回。
class _MemKV implements SecureKV {
final _m = <String, String>{};
@override
Future<String?> read(String key) async => _m[key];
@override
Future<void> write(String key, String value) async => _m[key] = value;
}
// 两节点都可用(status=up)。
class _StubNodes extends NodesNotifier {
@override
@@ -85,6 +96,7 @@ void main() {
ProviderContainer(overrides: [
vpnBridgeProvider.overrideWithValue(bridge),
nodesProvider.overrideWith(nodes ?? _StubNodes.new),
deviceIdentityProvider.overrideWithValue(DeviceIdentity(store: _MemKV())),
connectApiFactoryProvider.overrideWithValue((_) => _FakeConnectApi()),
connectionProvider.overrideWith(
(ref) => ConnectionController(ref, ref.watch(vpnBridgeProvider), now: now)),
@@ -101,19 +113,42 @@ void main() {
await tester.pump();
}
testWidgets('路径A:意外内核掉线(非用户主动)→ 置「节点异常」提示', (tester) async {
// #18:意外内核掉线(弱网最常见)先自动重连当前节点,不换节点、不立即报「节点异常」;
// 连续失败超过 _kMaxAutoReconnect(3) 次才真报「节点异常」。
testWidgets('#18 意外内核掉线(弱网)→ 先自动重连当前节点,超上限才报「节点异常」', (tester) async {
final bridge = _FakeBridge();
final c = makeContainer(bridge); // 节点都 up,排除路径B
final c = makeContainer(bridge); // 节点都 up(排除路径B);默认智能
addTearDown(bridge.dispose);
addTearDown(c.dispose);
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
await driveOn(tester, c, bridge);
expect(c.read(connectionProvider).phase, VpnPhase.on);
bridge.emit(VpnStatus.off); // 模拟节点数据面死 → REALITY 断 → 内核自报 off
c.read(nodesProvider);
c.read(connectionProvider); // 实例化控制器,使其订阅 bridge.statusStream(否则首个 emit 丢失)
await tester.pump();
final st = c.read(connectionProvider);
expect(st.phase, VpnPhase.off, reason: '内核掉线应回 off');
expect(st.error, t.nodeUnhealthyError, reason: '非用户主动掉线应给「节点异常」提示');
final initialSel = c.read(selectedNodeCodeProvider);
const maxRetries = 3; // = _kMaxAutoReconnect
Future<void> dropOnce() async {
bridge.emit(VpnStatus.on);
await tester.pump();
await tester.pump();
bridge.emit(VpnStatus.off); // 弱网:数据面瞬断 → 内核自报 off
// 泵到重连尝试(_connect fake 会失败回 off)settle,避免下一轮 emit(on) 与本轮 async 交错。
for (var k = 0; k < 15 && c.read(connectionProvider).phase != VpnPhase.off; k++) {
await tester.pump(const Duration(milliseconds: 10));
}
}
for (var i = 0; i < maxRetries; i++) {
await dropOnce();
expect(c.read(connectionProvider).error, isNot(t.nodeUnhealthyError),
reason: '${i + 1}次弱网掉线应自动重连,不应报「节点异常」');
expect(c.read(selectedNodeCodeProvider), initialSel, reason: '弱网不应换节点');
}
// 额度用尽 → 报「节点异常」。
await dropOnce();
expect(c.read(connectionProvider).phase, VpnPhase.off);
expect(c.read(connectionProvider).error, t.nodeUnhealthyError,
reason: '自动重连用尽后应给「节点异常」');
});
testWidgets('路径B:服务端判当前节点 down(智能)→ 切到其他可用节点', (tester) async {
@@ -127,17 +162,21 @@ void main() {
expect(c.read(selectedNodeCodeProvider), 'JP', reason: '智能模式应自动切到其他可用节点');
});
testWidgets('路径B:服务端判当前节点 down(手动)→ 断开 + 提示,不自动换', (tester) async {
// 路径B(服务端判当前节点 down)手动模式无备用:也走自动重连当前节点(不换、不立即报「节点异常」)。
// 重试用尽 → 报「节点异常」的额度逻辑由上面的 kernel-off 用例覆盖(同一 _tryAutoReconnectCurrent)。
testWidgets('#18 路径B 手动节点被判 down(无备用)→ 自动重连当前节点,不换、不立即报「节点异常」', (tester) async {
final bridge = _FakeBridge();
final c = makeContainer(bridge, nodes: _StubNodesHKDown.new);
addTearDown(bridge.dispose);
addTearDown(c.dispose);
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
c.read(selectedNodeCodeProvider.notifier).select('HK'); // 手动选定 HK(已 down)
await driveOn(tester, c, bridge);
final st = c.read(connectionProvider);
expect(st.phase, VpnPhase.off, reason: '手动模式节点 down 应断开');
expect(st.error, t.nodeUnhealthyError, reason: '应给节点异常提示');
c.read(selectedNodeCodeProvider.notifier).select('HK'); // 手动 HK(已 down),无备用
await driveOn(tester, c, bridge); // on → 看门狗 _checkHealth 判 HK down → _onNodeUnhealthy
for (var k = 0; k < 15 && c.read(connectionProvider).phase != VpnPhase.off; k++) {
await tester.pump(const Duration(milliseconds: 10));
}
expect(c.read(connectionProvider).error, isNot(t.nodeUnhealthyError),
reason: '首次判死应自动重连当前节点,不立即报「节点异常」');
expect(c.read(selectedNodeCodeProvider), 'HK', reason: '手动模式不应自动换节点');
});