diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 17db0e0..3e880e4 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -71,6 +71,7 @@ abstract class AppText { // 连通看门狗:当前节点数据面不可用时的提示 String get nodeUnhealthySwitched; // 智能选择已自动切换 String get nodeUnhealthyError; // 手动节点:断开并提示 + String get nodeReconnecting; // 弱网抖动:自动重连当前节点中(#18) String get smartSub; String get recommended; diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 3efdf73..b8935f8 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -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'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 11222d0..2be6386 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -95,6 +95,8 @@ class StringsZh extends AppText { @override String get nodeUnhealthyError => '当前节点异常,请重连或更换节点'; @override + String get nodeReconnecting => '网络波动,正在重连…'; + @override String get smartSub => '根据当前网络环境,自动选择最优节点'; @override String get recommended => '推荐'; diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index a134f0f..b9f94a2 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -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 { ConnectApi? _api; // 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。 Node? _connectedNode; + // 无备用节点时「先自动重连当前节点」的连续尝试计数;连接恢复健康(urltest 成功)或 + // 用户主动连接时清零,只有持续失败才耗尽额度后真报「节点异常」。#18 + int _autoReconnectAttempts = 0; // 免费版:连接时锁定的剩余额度(秒);连接期按「_freeRemainingSec − 已用秒」倒计时, // 归零即自动切断(_onFreeQuotaExhausted)。null = 会员/额度不限,不倒计时。 int? _freeRemainingSec; @@ -166,6 +171,7 @@ class ConnectionController extends StateNotifier { 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 { // 必须排除 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 { 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 { } 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 _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 _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 []) diff --git a/client/test/unit/connection_watchdog_test.dart b/client/test/unit/connection_watchdog_test.dart index e81556d..1eebd2c 100644 --- a/client/test/unit/connection_watchdog_test.dart +++ b/client/test/unit/connection_watchdog_test.dart @@ -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 = {}; + @override + Future read(String key) async => _m[key]; + @override + Future 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 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: '手动模式不应自动换节点'); });