diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index d8f2775..6362e8b 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -71,12 +71,16 @@ class ConnectionState { const _kWatchdogInterval = Duration(seconds: 15); // urltest 超过此时长未成功(连接态)即判上游(节点数据面)死。须 > 原生 urltest 探测间隔。 const _kUrltestStale = Duration(seconds: 45); +// stats 帧在此时长内到过即视为「在流」。超过(app 挂起/唤醒未恢复)时看门狗路径 A 不判活, +// 避免把 app 被挂起的空档错算成节点死。须 > 原生 stats 轮询间隔(~1s),留足余量。 +const _kStatsLive = Duration(seconds: 10); // ── 状态机 ─────────────────────────────────────────────────────── class ConnectionController extends StateNotifier { - ConnectionController(this._ref, this._bridge) - : super(const ConnectionState(phase: VpnPhase.off)) { + ConnectionController(this._ref, this._bridge, {DateTime Function()? now}) + : _now = now ?? DateTime.now, + super(const ConnectionState(phase: VpnPhase.off)) { // 订阅桥状态流:状态由内核事件驱动,严禁 UI 乐观翻转。 _statusSub = _bridge.statusStream.listen(_onKernelStatus); // 订阅内核 stats:连接态把 urltest 实测回写连接节点的 ping,让连接页与节点列表同源一致。 @@ -101,6 +105,11 @@ class ConnectionController extends StateNotifier { bool _probing = false; // 最近一次 urltest 成功(经 proxy 真实可达)的时刻;路径 A 用其陈旧度判上游死。 DateTime? _lastUrltestOk; + // 最近收到任意 stats 帧的时刻;用于区分「节点死(stats 在流、urltest 停)」与 + // 「app 挂起/睡眠(stats 整体停)」,避免后台唤醒后看门狗用墙上时钟误判节点死→假重连。 + DateTime? _lastStatsAt; + // 取「现在」的时钟(默认真实时钟,测试可注入以驱动 urltest 陈旧度/stats 在流判定)。 + final DateTime Function() _now; // 本次 off 的提示语(节点异常/自动切换);kernel off 与显式断开都读它,避免被互相覆盖。 // 仅 _connect 开始时清空。用户主动断开则保持 null(无提示)。 String? _offNotice; @@ -138,6 +147,7 @@ class ConnectionController extends StateNotifier { _offNotice = null; // 新连接:清掉上次的中断提示 _userDisconnect = false; _lastUrltestOk = null; // 重置 urltest 判活基准(连上后由 _onStats 首次成功置位) + _lastStatsAt = null; // 重置 stats 在流基准(连上后由 _onStats 首帧置位) state = const ConnectionState(phase: VpnPhase.connecting); final node = _ref.read(effectiveNodeProvider); @@ -245,6 +255,13 @@ class ConnectionController extends StateNotifier { // 内核(经 REALITY 真实直连数据面)是唯一能实测的;回写到 node.ping → 连接页/节点列表同源。 void _onStats(VpnStatsEvent e) { if (state.phase != VpnPhase.on) return; + final now = _now(); + // stats 出现长间断(app 挂起/睡眠唤醒)→ 此前的 urltest 时刻已不可信(墙上时钟空转期间 + // 没有探测)。把判活基准前移到现在,给节点一个新的 _kUrltestStale 窗口自证,避免误判假重连。 + if (_lastStatsAt != null && now.difference(_lastStatsAt!) > _kStatsLive) { + _lastUrltestOk = now; + } + _lastStatsAt = now; // 自动连接/重连到已在跑的隧道时不会走 _connect → _connectedNode 为 null;回退到 // effectiveNode(当前/智能所选),否则 urltest 实测无处回写、连接页延迟一直显示 —。 final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider); @@ -252,7 +269,7 @@ class ConnectionController extends StateNotifier { final ds = e.urltestResults.where((r) => r.delayMs > 0).map((r) => r.delayMs); if (ds.isEmpty) return; // urltest 成功:经 proxy 出站(REALITY 到节点)真实可达 → 记录时刻(供路径 A 判活)+ 回写延迟。 - _lastUrltestOk = DateTime.now(); + _lastUrltestOk = now; final best = ds.reduce((a, b) => a < b ? a : b); _ref.read(nodesProvider.notifier).setLivePing(node.uuid, best); } @@ -296,8 +313,12 @@ class ConnectionController extends StateNotifier { // 路径 A(跨端统一):urltest 曾成功过、但已超过 _kUrltestStale 未再成功 → 上游(节点 // 数据面)死。urltest 经 proxy 出站探测,四端都有;不依赖原生端把「上游断」报成 off/error。 final lastOk = _lastUrltestOk; - if (lastOk != null && DateTime.now().difference(lastOk) > _kUrltestStale) { - logLine('Watchdog', 'urltest stale ${DateTime.now().difference(lastOk).inSeconds}s → upstream dead → unhealthy'); + // stats 必须确实在流才判活:否则(app 刚挂起/唤醒、stats 还没恢复)看门狗会拿冻住的 + // 旧时刻误判。stats 未恢复时跳过本轮,等 _onStats 重置基准后再判。 + final now = _now(); + final statsLive = _lastStatsAt != null && now.difference(_lastStatsAt!) < _kStatsLive; + if (statsLive && lastOk != null && now.difference(lastOk) > _kUrltestStale) { + logLine('Watchdog', 'urltest stale ${now.difference(lastOk).inSeconds}s → upstream dead → unhealthy'); _stopWatchdog(); await _onNodeUnhealthy(); } diff --git a/client/test/unit/connection_watchdog_test.dart b/client/test/unit/connection_watchdog_test.dart index d3a126c..0cff43b 100644 --- a/client/test/unit/connection_watchdog_test.dart +++ b/client/test/unit/connection_watchdog_test.dart @@ -19,6 +19,7 @@ class _FakeBridge implements VpnBridge { final _status = StreamController.broadcast(); final _stats = StreamController.broadcast(); void emit(VpnStatus s) => _status.add(s); + void emitStats(VpnStatsEvent e) => _stats.add(e); @override Stream get statusStream => _status.stream; @override @@ -78,13 +79,14 @@ class _StubNodesHKDown extends NodesNotifier { void main() { const t = StringsZh(); - ProviderContainer makeContainer(_FakeBridge bridge, {NodesNotifier Function()? nodes}) => + ProviderContainer makeContainer(_FakeBridge bridge, + {NodesNotifier Function()? nodes, DateTime Function()? now}) => ProviderContainer(overrides: [ vpnBridgeProvider.overrideWithValue(bridge), nodesProvider.overrideWith(nodes ?? _StubNodes.new), connectApiFactoryProvider.overrideWithValue((_) => _FakeConnectApi()), connectionProvider.overrideWith( - (ref) => ConnectionController(ref, ref.watch(vpnBridgeProvider))), + (ref) => ConnectionController(ref, ref.watch(vpnBridgeProvider), now: now)), ]); // 加载节点 + 建 controller + 推 on,并让「即时首查」跑完。 @@ -138,6 +140,69 @@ void main() { expect(c.read(selectedNodeCodeProvider), 'HK', reason: '手动模式不应自动换节点'); }); + // urltest 帧(经 proxy 真实可达):置/刷新判活基准。 + VpnStatsEvent urltestFrame(int delayMs) => VpnStatsEvent( + uploadBytes: 0, downloadBytes: 0, uploadSpeed: 0, downloadSpeed: 0, + urltestResults: [UrltestResult(tag: 'hk', delayMs: delayMs)], + ); + // 仅速率帧(无 urltest):证明 stats 仍在流,但不刷新 urltest 判活基准。 + const speedOnlyFrame = VpnStatsEvent( + uploadBytes: 1, downloadBytes: 1, uploadSpeed: 1, downloadSpeed: 1, urltestResults: []); + + // 注:看门狗 tick 由假定时器(tester.pump 推进)驱动,但陈旧度判定用注入时钟 `fake`; + // 故下面同时推进 fake(墙上时钟)与 pump(触发 tick),复现真机时间流逝。 + testWidgets('路径A:stats 在流但 urltest 停 ≥45s → 智能切到其他节点', (tester) async { + final bridge = _FakeBridge(); + var fake = DateTime(2026, 1, 1); + final c = makeContainer(bridge, now: () => fake); // 都 up,排除路径B;默认智能,最优 HK + addTearDown(bridge.dispose); + addTearDown(c.dispose); + await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); + await driveOn(tester, c, bridge); + bridge.emitStats(urltestFrame(20)); // 基准:一次正常 urltest(lastOk=t0) + await tester.pump(); + // 节点死但本地 TUN 还在:stats 持续在流(每 8s 一帧、gap<10s 不触发重置),urltest 停。 + // 墙上时钟累计走过 45s → lastOk 仍停在 t0、statsAt 一路推进(在流)。 + for (var i = 0; i < 7; i++) { + fake = fake.add(const Duration(seconds: 8)); + bridge.emitStats(speedOnlyFrame); + await tester.pump(); + } + await tester.pump(const Duration(seconds: 15)); // 驱动一次看门狗 tick + await tester.pump(); + expect(c.read(selectedNodeCodeProvider), 'JP', + reason: 'stats 在流而 urltest 停 ≥45s → 路径A 判节点死,智能应切到 JP'); + }); + + testWidgets('app 挂起唤醒:stats 整体停后恢复 → 不假重连', (tester) async { + final bridge = _FakeBridge(); + var fake = DateTime(2026, 1, 1); + final c = makeContainer(bridge, now: () => fake); // 都 up,排除路径B + addTearDown(bridge.dispose); + addTearDown(c.dispose); + await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); + c.read(selectedNodeCodeProvider.notifier).select('HK'); + await driveOn(tester, c, bridge); + bridge.emitStats(urltestFrame(20)); // 连上后一次正常 urltest:lastOk=t0, statsAt=t0 + await tester.pump(); + // 模拟 app 挂起/睡眠 60s:墙上时钟跳进,但期间无任何 stats 帧(stats 全停)。 + fake = fake.add(const Duration(seconds: 60)); + await tester.pump(const Duration(seconds: 15)); // 驱动看门狗 tick(此刻 statsAt 仍停在 t0) + await tester.pump(); + // 修复前:lastOk 看似陈旧(60s)→ 误判节点死 → 智能切 JP。 + // 修复后:statsLive=false(statsAt 距今 60s)→ 路径A 跳过 → 保持 HK。 + expect(c.read(connectionProvider).phase, VpnPhase.on, reason: '隧道没掉,应保持连接'); + expect(c.read(selectedNodeCodeProvider), 'HK', reason: '挂起空档不应被误判为节点死而切节点'); + // 唤醒:恢复 stats(gap>10s → _onStats 把 lastOk 前移到现在),后续仍不应切。 + bridge.emitStats(urltestFrame(22)); + await tester.pump(); + await tester.pump(const Duration(seconds: 15)); + await tester.pump(); + expect(c.read(selectedNodeCodeProvider), 'HK', reason: '唤醒后基准刷新,保持 HK'); + bridge.emit(VpnStatus.off); // 收尾 + await tester.pump(); + }); + testWidgets('节点健康 → 保持连接,不触发任何动作', (tester) async { final bridge = _FakeBridge(); final c = makeContainer(bridge); // 都 up