From 37c1724b6798639624b7032bd2f6c1562db947d9 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 1 Jul 2026 08:54:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(client):=20=E5=88=87=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=81=9C=E7=9C=8B=E9=97=A8=E7=8B=97=20=E2=80=94=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=20urltest=20=E8=A2=AB=E9=99=90=E6=B5=81=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=AF=AF=E5=88=A4=E8=8A=82=E7=82=B9=E6=AD=BB=E6=96=AD?= =?UTF-8?q?=E8=BF=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机(Honor/MagicOS)复现:连上后切后台 ~1min 必断,报「当前节点异常」。 logcat 铁证:进程存活、Dart 看门狗后台照跑, [Watchdog] urltest stale 59s → upstream dead → unhealthy → doStop 根因:后台(Doze)把 urltest 主动探测限流 → urltest 停更,但 speed 帧仍在流 → statsLive=true + urltest 陈旧 → 路径A 误判上游死,把正常隧道断掉。上次的 statsLive 闸只挡「stats 整体停」(macOS 睡眠),挡不住「speed 在流、只 urltest 停」。 修:看门狗是前台 UX 功能(发现节点死好让用户换),后台无 UI 可响应、urltest 必被限流 → AppLifecycleListener 在 paused/hidden 停看门狗,resumed 且仍连接则重启(基准由 _onStats 首帧/gap 重置)。真·内核掉线(路径A',事件驱动)不受影响,仍上报。 测试:connection_watchdog 新增「切后台停看门狗」回归(后台 speed 在流+urltest 停 >45s 不切节点);全 7 例过。 Co-Authored-By: Claude Opus 4.8 --- client/lib/state/connection_provider.dart | 16 ++++++++++ .../test/unit/connection_watchdog_test.dart | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+) 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