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
+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: '手动模式不应自动换节点');
});