fix(client): 看门狗返工 — 意外掉线捕获+服务端down轮询,撤销TUN下假阳性的TCP探测
测试暴露:全局TUN下App的socket到节点IP被本地TUN当场接住(假1ms),客户端TCP探数据口 不可行;且节点死时内核先掉线、提示被kernel off覆盖、节点页status不刷新显示绿色。返工: - 撤销 DataPlaneProber/livePingMs;延迟改回内核urltest(sing-box经REALITY真实直连数据面 探出的RTT,唯一可靠口径),连接态urltest=0→显示—不回退旧ping。 - 看门狗两条可靠路径:A 捕获「非用户主动的内核掉线」→置「节点异常」+刷列表; B 连接态周期刷/v1/nodes,所连节点被判down→智能切/手动断。 - _offNotice 让显式断开与kernel off读同一提示字段,不再互相覆盖(修「断开但没报错」)。 - 节点页周期15s刷新,让服务端down状态浮现(tile已按isDown置灰)。 返工测试:意外掉线提示/服务端down切/断/健康保持,全过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// connection_watchdog_test.dart — 连通看门狗:已连接后探测连续失败时,
|
||||
// 智能选择自动切到其他节点;手动选定的节点只断开并提示。
|
||||
// connection_watchdog_test.dart — 连通看门狗(返工版,数据面 IP 探测已撤):
|
||||
// 路径 A 意外内核掉线(非用户主动)→ 置「节点异常」提示;
|
||||
// 路径 B 服务端判所连节点 down → 智能切节点 / 手动断开提示;健康则保持连接。
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -52,6 +53,7 @@ class _FakeConnectApi extends ConnectApi {
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
// 两节点都可用(status=up)。
|
||||
class _StubNodes extends NodesNotifier {
|
||||
@override
|
||||
Future<List<Node>> build() async => const [
|
||||
@@ -62,7 +64,7 @@ class _StubNodes extends NodesNotifier {
|
||||
Future<void> refresh() async {}
|
||||
}
|
||||
|
||||
// 当前最优节点 HK 被服务端判 down(status≠up),JP 仍可用 —— 用于验证信号③。
|
||||
// 当前最优节点 HK 被服务端判 down(status≠up),JP 仍可用。
|
||||
class _StubNodesHKDown extends NodesNotifier {
|
||||
@override
|
||||
Future<List<Node>> build() async => const [
|
||||
@@ -76,94 +78,81 @@ class _StubNodesHKDown extends NodesNotifier {
|
||||
void main() {
|
||||
const t = StringsZh();
|
||||
|
||||
ProviderContainer makeContainer(_FakeBridge bridge, {required bool healthy}) =>
|
||||
ProviderContainer makeContainer(_FakeBridge bridge, {NodesNotifier Function()? nodes}) =>
|
||||
ProviderContainer(overrides: [
|
||||
vpnBridgeProvider.overrideWithValue(bridge),
|
||||
nodesProvider.overrideWith(_StubNodes.new),
|
||||
nodesProvider.overrideWith(nodes ?? _StubNodes.new),
|
||||
connectApiFactoryProvider.overrideWithValue((_) => _FakeConnectApi()),
|
||||
connectionProvider.overrideWith((ref) => ConnectionController(
|
||||
ref, ref.watch(vpnBridgeProvider),
|
||||
// 数据面 TCP 探测:健康→返回 RTT 30ms,不健康→0(不可达)。
|
||||
prober: (host, port) async => healthy ? 30 : 0,
|
||||
)),
|
||||
connectionProvider.overrideWith(
|
||||
(ref) => ConnectionController(ref, ref.watch(vpnBridgeProvider))),
|
||||
]);
|
||||
|
||||
// 驱动到 on,再走 3 个看门狗周期(每个 15s)。
|
||||
Future<void> driveOnAndProbe(WidgetTester tester, ProviderContainer c, _FakeBridge bridge) async {
|
||||
c.read(nodesProvider); // 触发(异步)节点加载
|
||||
await tester.pump(); // 等 valueOrNull 就绪(智能切换要读节点列表)
|
||||
c.read(connectionProvider); // 建 controller(订阅状态流)
|
||||
bridge.emit(VpnStatus.on);
|
||||
await tester.pump();
|
||||
expect(c.read(connectionProvider).phase, VpnPhase.on);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tester.pump(const Duration(seconds: 15));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('智能模式:探测连续失败 → 自动切到其他最优节点', (tester) async {
|
||||
final bridge = _FakeBridge();
|
||||
final c = makeContainer(bridge, healthy: false);
|
||||
addTearDown(bridge.dispose);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
|
||||
// 默认即智能选择(selectedNodeCode = AUTO);有效节点取最小延迟 HK,故应切到 JP。
|
||||
await driveOnAndProbe(tester, c, bridge);
|
||||
expect(c.read(selectedNodeCodeProvider), 'JP', reason: '智能模式应自动切到其他最优节点');
|
||||
});
|
||||
|
||||
testWidgets('手动选定节点:探测连续失败 → 断开并提示,不自动换', (tester) async {
|
||||
final bridge = _FakeBridge();
|
||||
final c = makeContainer(bridge, healthy: false);
|
||||
addTearDown(bridge.dispose);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
|
||||
c.read(selectedNodeCodeProvider.notifier).select('HK'); // 手动选定 HK
|
||||
await driveOnAndProbe(tester, c, bridge);
|
||||
expect(c.read(selectedNodeCodeProvider), 'HK', reason: '手动模式不应自动换节点');
|
||||
final st = c.read(connectionProvider);
|
||||
expect(st.phase, VpnPhase.off, reason: '应断开');
|
||||
expect(st.error, t.nodeUnhealthyError, reason: '应给出节点异常提示');
|
||||
});
|
||||
|
||||
testWidgets('探测正常:不触发任何动作', (tester) async {
|
||||
final bridge = _FakeBridge();
|
||||
final c = makeContainer(bridge, healthy: true);
|
||||
addTearDown(bridge.dispose);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
|
||||
c.read(selectedNodeCodeProvider.notifier).state = 'HK';
|
||||
await driveOnAndProbe(tester, c, bridge);
|
||||
expect(c.read(connectionProvider).phase, VpnPhase.on, reason: '健康时应保持连接');
|
||||
expect(c.read(selectedNodeCodeProvider), 'HK');
|
||||
bridge.emit(VpnStatus.off); // 收尾:停看门狗/计时器,避免 pending timer
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('信号③:服务端判当前节点 down → 即使 TCP 探测正常也切换', (tester) async {
|
||||
final bridge = _FakeBridge();
|
||||
final c = ProviderContainer(overrides: [
|
||||
vpnBridgeProvider.overrideWithValue(bridge),
|
||||
nodesProvider.overrideWith(_StubNodesHKDown.new),
|
||||
connectApiFactoryProvider.overrideWithValue((_) => _FakeConnectApi()),
|
||||
connectionProvider.overrideWith((ref) => ConnectionController(
|
||||
ref, ref.watch(vpnBridgeProvider),
|
||||
prober: (host, port) async => 30, // TCP 健康,证明触发来自服务端 down 信号而非探测
|
||||
)),
|
||||
]);
|
||||
addTearDown(bridge.dispose);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
|
||||
// 智能模式:当前最优 HK 被服务端判 down → 应切到 JP(唯一可用)。
|
||||
// 不复用 driveOnAndProbe:服务端 down 在「即时首测」就触发切换,相位已离开 on。
|
||||
// 加载节点 + 建 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(); // 即时首测:serverDown=true → 立即 _onNodeUnhealthy
|
||||
await tester.pump();
|
||||
expect(c.read(selectedNodeCodeProvider), 'JP', reason: '服务端判当前节点 down,智能模式应切到其他可用节点');
|
||||
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: '手动模式不应自动换节点');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user