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:
@@ -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: '手动模式不应自动换节点');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user