Files
pangolin/client/test/unit/connection_watchdog_test.dart
T
wangjia 37c1724b67 fix(client): 切后台停看门狗 — 后台 urltest 被限流不再误判节点死断连
真机(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 <noreply@anthropic.com>
2026-07-01 08:54:16 +08:00

254 lines
12 KiB
Dart

// connection_watchdog_test.dart — 连通看门狗(返工版,数据面 IP 探测已撤):
// 路径 A 意外内核掉线(非用户主动)→ 置「节点异常」提示;
// 路径 B 服务端判所连节点 down → 智能切节点 / 手动断开提示;健康则保持连接。
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
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/state/connection_provider.dart';
import 'package:pangolin_vpn/state/nodes_provider.dart';
// 可控假桥:能手动推 VpnStatus,无内部计时器(避免 pending timer)。
class _FakeBridge implements VpnBridge {
final _status = StreamController<VpnStatus>.broadcast();
final _stats = StreamController<VpnStatsEvent>.broadcast();
void emit(VpnStatus s) => _status.add(s);
void emitStats(VpnStatsEvent e) => _stats.add(e);
@override
Stream<VpnStatus> get statusStream => _status.stream;
@override
Stream<VpnStatsEvent> get statsStream => _stats.stream;
@override
Future<void> start(String configJson) async {}
@override
Future<void> stop() async {}
@override
Future<VpnStatus> getStatus() async => VpnStatus.off;
@override
Future<void> selectOutbound(String tag) async {}
@override
Future<String> getActiveOutbound() async => 'auto';
@override
Future<void> setKillSwitch({required bool on}) async {}
@override
void dispose() {
_status.close();
_stats.close();
}
}
// 重连时 fetchConfig 立即抛错,绕开真实网络(不留超时计时器)。
class _FakeConnectApi extends ConnectApi {
_FakeConnectApi() : super(baseUrl: 'http://test.local', authToken: '');
@override
Future<String> fetchConfig({required String nodeId, required String deviceId, bool splitCN = false}) async {
throw const ConnectApiException(statusCode: 0, messageZh: 'x', messageEn: 'x');
}
@override
void dispose() {}
}
// 两节点都可用(status=up)。
class _StubNodes extends NodesNotifier {
@override
Future<List<Node>> build() async => const [
Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 20, uuid: 'hk-uuid', host: 'hk', port: 443),
Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443),
];
@override
Future<void> refresh() async {}
}
// 当前最优节点 HK 被服务端判 down(status≠up),JP 仍可用。
class _StubNodesHKDown extends NodesNotifier {
@override
Future<List<Node>> build() async => const [
Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 20, uuid: 'hk-uuid', host: 'hk', port: 443, status: 'down'),
Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443),
];
@override
Future<void> refresh() async {}
}
void main() {
const t = StringsZh();
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), now: now)),
]);
// 加载节点 + 建 controller + 推 on,并让「即时首查」跑完。
Future<void> driveOn(WidgetTester tester, ProviderContainer c, _FakeBridge bridge) async {
c.read(nodesProvider);
await tester.pump();
c.read(connectionProvider);
bridge.emit(VpnStatus.on);
await tester.pump();
await tester.pump();
await tester.pump();
}
testWidgets('路径A:意外内核掉线(非用户主动)→ 置「节点异常」提示', (tester) async {
final bridge = _FakeBridge();
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
await tester.pump();
final st = c.read(connectionProvider);
expect(st.phase, VpnPhase.off, reason: '内核掉线应回 off');
expect(st.error, t.nodeUnhealthyError, reason: '非用户主动掉线应给「节点异常」提示');
});
testWidgets('路径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()));
// 默认智能:有效节点取最优 HK,被判 down → 应切到 JP。
await driveOn(tester, c, bridge);
expect(c.read(selectedNodeCodeProvider), 'JP', reason: '智能模式应自动切到其他可用节点');
});
testWidgets('路径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: '应给节点异常提示');
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('切后台停看门狗:后台 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
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);
for (var i = 0; i < 3; i++) {
await tester.pump(const Duration(seconds: 15));
await tester.pump();
}
expect(c.read(connectionProvider).phase, VpnPhase.on, reason: '健康时应保持连接');
expect(c.read(selectedNodeCodeProvider), 'HK');
bridge.emit(VpnStatus.off); // 收尾:停看门狗/计时器
await tester.pump();
});
}