diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 6362e8b..63118e2 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -7,6 +7,7 @@ // - 状态来源:bridge.statusStream(来自内核回调),非 Timer 模拟。 import 'dart:async'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../bridge/log.dart'; @@ -93,6 +94,19 @@ class ConnectionController extends StateNotifier { unawaited(_disconnect()); } }); + // 生命周期闸:切后台停看门狗,回前台再开。原因:后台(尤其 Android Doze)会把 urltest + // 的主动探测限流 → urltest 停更、但 speed 帧仍在流 → 路径 A 误判「上游死」把正常隧道断掉 + // (实测 Honor 后台 ~1min 必现:logcat「urltest stale 59s → upstream dead」)。后台无 UI 可 + // 响应,停掉最稳;回前台基准由 _onStats 首帧/gap 重置。真·内核掉线(路径 A',事件驱动)不受影响。 + _lifecycle = AppLifecycleListener( + onStateChange: (s) { + if (s == AppLifecycleState.resumed) { + if (state.phase == VpnPhase.on && _watchdog == null) _startWatchdog(); + } else if (s == AppLifecycleState.paused || s == AppLifecycleState.hidden) { + _stopWatchdog(); + } + }, + ); } final Ref _ref; @@ -102,6 +116,7 @@ class ConnectionController extends StateNotifier { ProviderSubscription? _authSub; Timer? _elapsed; Timer? _watchdog; + AppLifecycleListener? _lifecycle; bool _probing = false; // 最近一次 urltest 成功(经 proxy 真实可达)的时刻;路径 A 用其陈旧度判上游死。 DateTime? _lastUrltestOk; @@ -381,6 +396,7 @@ class ConnectionController extends StateNotifier { _authSub?.close(); _stopElapsed(); _stopWatchdog(); + _lifecycle?.dispose(); _api?.dispose(); super.dispose(); } diff --git a/client/test/unit/connection_watchdog_test.dart b/client/test/unit/connection_watchdog_test.dart index 0cff43b..6157795 100644 --- a/client/test/unit/connection_watchdog_test.dart +++ b/client/test/unit/connection_watchdog_test.dart @@ -203,6 +203,36 @@ void main() { await tester.pump(); }); + testWidgets('切后台停看门狗:后台 urltest 停而 speed 在流,不误判断连', (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)); // lastOk=t0 + await tester.pump(); + // 切后台(须走合法生命周期序列 resumed→inactive→hidden→paused)。 + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + // 复现 Android 后台:speed 帧继续(每 8s、statsLive),urltest 停,墙上时钟走 >45s。 + // 看门狗若仍在跑会误判 path A 切到 JP;已停则不动。 + for (var i = 0; i < 8; i++) { + fake = fake.add(const Duration(seconds: 8)); + bridge.emitStats(speedOnlyFrame); + await tester.pump(); + await tester.pump(const Duration(seconds: 15)); + } + expect(c.read(selectedNodeCodeProvider), 'HK', reason: '后台看门狗已停,不应误判 path A 切节点'); + expect(c.read(connectionProvider).phase, VpnPhase.on, reason: '隧道保持连接'); + bridge.emit(VpnStatus.off); // 收尾 + await tester.pump(); + }); + testWidgets('节点健康 → 保持连接,不触发任何动作', (tester) async { final bridge = _FakeBridge(); final c = makeContainer(bridge); // 都 up