From d9378b672919d5f6800ea27475a3276f7f146bf0 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 29 Jun 2026 23:21:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(client):=20=E7=9C=8B=E9=97=A8=E7=8B=97?= =?UTF-8?q?=E8=BF=94=E5=B7=A5=20=E2=80=94=20=E6=84=8F=E5=A4=96=E6=8E=89?= =?UTF-8?q?=E7=BA=BF=E6=8D=95=E8=8E=B7+=E6=9C=8D=E5=8A=A1=E7=AB=AFdown?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2,=E6=92=A4=E9=94=80TUN=E4=B8=8B=E5=81=87?= =?UTF-8?q?=E9=98=B3=E6=80=A7=E7=9A=84TCP=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测试暴露:全局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 --- client/lib/screens/connect_page.dart | 18 ++- client/lib/screens/nodes_page.dart | 13 ++ client/lib/state/connection_provider.dart | 110 +++++-------- .../test/unit/connection_watchdog_test.dart | 153 ++++++++---------- 4 files changed, 142 insertions(+), 152 deletions(-) diff --git a/client/lib/screens/connect_page.dart b/client/lib/screens/connect_page.dart index 93c465e..a229478 100644 --- a/client/lib/screens/connect_page.dart +++ b/client/lib/screens/connect_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../bridge/vpn_bridge.dart'; import '../core/responsive/form_factor.dart'; import '../util/format.dart'; import '../l10n/app_text.dart'; @@ -40,10 +41,12 @@ class ConnectPage extends ConsumerWidget { } final down = formatSpeed(stats?.downloadSpeed); final up = formatSpeed(stats?.uploadSpeed); - // 延迟来源:连接后用看门狗对数据面 IP(节点 host:443)的 TCP 握手 RTT(与判活同源, - // 见 connection_provider);首测前/不可达回退节点页 TCP 探针 ping。都没有才 —。 - var livePing = conn.phase == VpnPhase.on ? (conn.livePingMs ?? 0) : node.ping; - if (livePing <= 0) livePing = node.ping; + // 延迟 = client→数据面 RTT,按连接态取不同测法(全局 TUN 下 App socket 测不到真节点): + // · 连接态:内核 urltest —— sing-box 经 REALITY 真实直连数据面探出的 RTT(唯一可靠口径); + // 节点死 → urltest=0 → 落到 —,本身即健康信号。 + // · 断开态:节点页 TCP 探针(直接握手 节点:443,无 TUN,真实 RTT)。 + var livePing = conn.phase == VpnPhase.on ? _bestUrltest(stats) : node.ping; + if (livePing <= 0 && conn.phase != VpnPhase.on) livePing = node.ping; final pingLabel = livePing > 0 ? '${livePing}ms' : '—'; final latencyValue = livePing > 0 ? '$livePing' : '—'; @@ -207,6 +210,13 @@ class ConnectPage extends ConsumerWidget { } } +/// 内核 urltest 各出站中的最小正延迟(ms);无则 0。连接态用作 client→数据面 RTT。 +int _bestUrltest(VpnStatsEvent? s) { + if (s == null) return 0; + final ds = s.urltestResults.where((r) => r.delayMs > 0).map((r) => r.delayMs); + return ds.isEmpty ? 0 : ds.reduce((a, b) => a < b ? a : b); +} + /// 实时速率行(连接成功时显示;来自内核 statsStream 实时数据)。 class _SpeedRow extends StatelessWidget { const _SpeedRow({required this.t, required this.down, required this.up, required this.latency}); diff --git a/client/lib/screens/nodes_page.dart b/client/lib/screens/nodes_page.dart index ab5b605..03e2ff4 100644 --- a/client/lib/screens/nodes_page.dart +++ b/client/lib/screens/nodes_page.dart @@ -1,4 +1,6 @@ // nodes_page.dart — 节点页(置顶智能选择推荐卡 + 搜索 + 列表/双列网格) +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -27,6 +29,7 @@ class NodesPage extends ConsumerStatefulWidget { class _NodesPageState extends ConsumerState { String _q = ''; bool _refreshing = false; + Timer? _poll; @override void initState() { @@ -35,6 +38,16 @@ class _NodesPageState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _refresh(); }); + // 在页期间周期刷新,让服务端节点 status(如 down)及时浮现到列表(置灰/不可用)。 + _poll = Timer.periodic(const Duration(seconds: 15), (_) { + if (mounted) ref.read(nodesProvider.notifier).refresh(); + }); + } + + @override + void dispose() { + _poll?.cancel(); + super.dispose(); } Future _refresh() async { diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 4b36405..7f144bb 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -17,7 +17,6 @@ import '../models/node.dart'; import '../services/api_config.dart'; import '../services/connect_api.dart'; import '../services/device_identity.dart'; -import '../services/latency_probe.dart'; import 'app_providers.dart'; import 'auth_provider.dart'; import 'nodes_provider.dart'; @@ -34,79 +33,62 @@ enum VpnPhase { off, connecting, on } // ── 连接状态快照 ────────────────────────────────────────────────── class ConnectionState { - const ConnectionState( - {required this.phase, this.elapsed = Duration.zero, this.error, this.livePingMs}); + const ConnectionState({required this.phase, this.elapsed = Duration.zero, this.error}); final VpnPhase phase; final Duration elapsed; - /// 连接失败原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。 + /// 连接失败/中断原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。 final String? error; - /// 连接期实测延迟(ms):看门狗周期性对数据面 IP(节点 host:443)做 TCP 握手得到。 - /// >0 = 可达 RTT;0 = 数据口不可达(UI 显示 —);null = 尚未测。与看门狗同源(见下)。 - final int? livePingMs; - - /// 注意:error 不随 copyWith 传递(瞬时提示用毕即弃);livePingMs 默认沿用(计时器 - /// 每秒 copyWith(elapsed:) 不应清掉延迟)。要改延迟显式传 livePingMs。 - ConnectionState copyWith({VpnPhase? phase, Duration? elapsed, int? livePingMs}) => - ConnectionState( - phase: phase ?? this.phase, - elapsed: elapsed ?? this.elapsed, - livePingMs: livePingMs ?? this.livePingMs, - ); + ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) => + ConnectionState(phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed); @override bool operator ==(Object other) => other is ConnectionState && other.phase == phase && other.elapsed == elapsed && - other.error == error && - other.livePingMs == livePingMs; + other.error == error; @override - int get hashCode => Object.hash(phase, elapsed, error, livePingMs); + int get hashCode => Object.hash(phase, elapsed, error); } // ── 连通看门狗 ───────────────────────────────────────────────────── // -// 已连接(本地 TUN 已起)≠ 远端节点数据面可用。看门狗周期性融合三路信号判活,任一 -// 命中即判当前所连节点不可用 → 智能选择自动切节点 / 手动选定的只断开提示: -// ① 客户端→数据口 TCP 可达性:直连节点 host:443(REALITY 数据口)做 TCP 握手。 -// 节点 IP 在 TUN 内必判 direct(否则代理连自己死循环)→ 探测走物理网卡直奔真实 -// 节点,不被分流糊弄。连续 N 次握不上 = 本客户端到数据口不通(覆盖 ISP/GFW 封端口/IP -// 等服务端看不到的 per-client 链路问题)。兼测延迟(②)。 -// ② 延迟同源:①的握手 RTT 即连接页延迟,挂了掉成 — 本身就是可视信号。 -// ③ 服务端权威健康:刷 /v1/nodes,所连节点被判 down(dp_healthy=0 / agent 掉线 / -// 运维下线)即处理。覆盖「443 还开着但节点失管/被下线/凭证推不下去」等 TCP 探不到的。 -// 关键:探测/判活针对「实际所连节点」(_connectedNode),非 effectiveNode——智能模式下 -// effectiveNode 会随 ping 变化自动飘到别的节点,会漏判当前节点已挂。 - -/// 数据面探测接缝:对节点数据口 (host, port=REALITY 443) 做 TCP 握手,返回 RTT(ms), -/// 0=不可达。默认走 latency_probe;测试可注入假实现。看门狗判活 + 连接页延迟两用,单一口径。 -typedef DataPlaneProber = Future Function(String host, int port); +// 已连接(本地 TUN 已起)≠ 远端节点数据面可用。两条可靠路径发现节点异常: +// A. 意外内核掉线:节点数据面死 → REALITY 断 → 内核自报 off/error。凡「非用户主动」 +// 的掉线一律判为节点异常 → 置 error「节点异常,连接已中断」+ 刷节点列表(让 down 浮现)。 +// 这是节点真死时最先、最可靠的信号。见 _onKernelStatus。 +// B. 服务端权威 down:看门狗连接态周期刷 /v1/nodes,所连节点被判 down(dp_healthy=0 / +// agent 掉线 / 运维下线)即处理。覆盖「本地 TUN 还没断、但节点已被判不可用」。 +// 注:不再做客户端 TCP 探数据口——全局 TUN 下 App 的 socket 被本地 TUN 当场接住(假 1ms), +// 测不到真节点。延迟改由内核 urltest(握着到数据面真实直连)给,见 connect_page。 +// 判活针对「实际所连节点」(_connectedNode),非会随 ping 漂移的 effectiveNode。 const _kWatchdogInterval = Duration(seconds: 15); -const _kHealthFailLimit = 3; // 连续 N 次握不上(≈45s)才判定不可用,防抖 // ── 状态机 ─────────────────────────────────────────────────────── class ConnectionController extends StateNotifier { - ConnectionController(this._ref, this._bridge, {DataPlaneProber? prober}) - : _prober = prober ?? probeLatency, - super(const ConnectionState(phase: VpnPhase.off)) { + ConnectionController(this._ref, this._bridge) + : super(const ConnectionState(phase: VpnPhase.off)) { // 订阅桥状态流:状态由内核事件驱动,严禁 UI 乐观翻转。 _statusSub = _bridge.statusStream.listen(_onKernelStatus); } final Ref _ref; final VpnBridge _bridge; - final DataPlaneProber _prober; StreamSubscription? _statusSub; Timer? _elapsed; Timer? _watchdog; - int _healthFails = 0; bool _probing = false; + // 本次 off 的提示语(节点异常/自动切换);kernel off 与显式断开都读它,避免被互相覆盖。 + // 仅 _connect 开始时清空。用户主动断开则保持 null(无提示)。 + String? _offNotice; + // 标记「用户主动断开」:其引发的 kernel off 不当作节点异常。 + bool _userDisconnect = false; ConnectApi? _api; // 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。 Node? _connectedNode; @@ -119,6 +101,7 @@ class ConnectionController extends StateNotifier { case VpnPhase.off: _connect(); case VpnPhase.on: + _userDisconnect = true; // 用户主动断开:其 kernel off 不当作节点异常 _disconnect(); case VpnPhase.connecting: break; // 握手进行中,不响应 @@ -135,6 +118,8 @@ class ConnectionController extends StateNotifier { // ── 内部 ───────────────────────────────────────────────────── Future _connect() async { + _offNotice = null; // 新连接:清掉上次的中断提示 + _userDisconnect = false; state = const ConnectionState(phase: VpnPhase.connecting); final node = _ref.read(effectiveNodeProvider); @@ -201,10 +186,12 @@ class ConnectionController extends StateNotifier { Future _disconnect() async { _stopElapsed(); + _stopWatchdog(); try { await _bridge.stop(); } catch (_) {} - if (mounted) state = const ConnectionState(phase: VpnPhase.off); + // 携带 _offNotice(节点异常/null);与 kernel off 读同一字段,不互相覆盖。 + if (mounted) state = ConnectionState(phase: VpnPhase.off, error: _offNotice); } void _onKernelStatus(VpnStatus s) { @@ -220,24 +207,31 @@ class ConnectionController extends StateNotifier { _stopWatchdog(); case VpnStatus.off: case VpnStatus.error: - state = const ConnectionState(phase: VpnPhase.off); + final wasActive = state.phase == VpnPhase.on || state.phase == VpnPhase.connecting; + // 路径 A:非用户主动、且本来在连/已连 → 节点数据面死导致的意外掉线。 + // 给原因(节点异常)+ 刷节点列表让服务端 down 浮现。_offNotice 让显式断开与本回调统一。 + if (!_userDisconnect && wasActive && _offNotice == null) { + _offNotice = _ref.read(appTextProvider).nodeUnhealthyError; + logLine('Watchdog', 'unexpected kernel ${s.name} while $wasActive → node interrupted'); + unawaited(_ref.read(nodesProvider.notifier).refresh()); + } + state = ConnectionState(phase: VpnPhase.off, error: _offNotice); + _userDisconnect = false; _stopElapsed(); _stopWatchdog(); } } - // ── 连通看门狗 ─────────────────────────────────────────────── + // ── 连通看门狗(路径 B:服务端权威 down 轮询)─────────────────────── void _startWatchdog() { _watchdog?.cancel(); - _healthFails = 0; _watchdog = Timer.periodic(_kWatchdogInterval, (_) => _checkHealth()); - unawaited(_checkHealth()); // 立即测一次:尽快回填延迟 + 早发现数据面异常 + unawaited(_checkHealth()); // 立即查一次,早发现「TUN 未断但节点已被判 down」 } void _stopWatchdog() { _watchdog?.cancel(); _watchdog = null; - _healthFails = 0; _probing = false; } @@ -247,7 +241,6 @@ class ConnectionController extends StateNotifier { final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider); if (node.uuid.isEmpty) return; _probing = true; - // 信号③:服务端权威健康——刷 /v1/nodes,看所连节点是否被判 down。 var serverDown = false; try { await _ref.read(nodesProvider.notifier).refresh(); @@ -255,34 +248,18 @@ class ConnectionController extends StateNotifier { .where((n) => n.uuid == node.uuid); if (cur.isNotEmpty) serverDown = cur.first.isDown; } catch (_) { - // 拉取失败不视为不健康(refresh 失败保留旧列表);本轮以 TCP 探测为准。 + // 拉取失败保留旧列表,不误判;等下一轮。 } - // 信号①②:对数据口 TCP 握手,得 RTT(0=不可达);RTT 同时回填连接页延迟。 - final rtt = await _prober(node.host, node.port); _probing = false; if (!mounted || state.phase != VpnPhase.on) return; - final live = rtt > 0 ? rtt : 0; - if (state.livePingMs != live) state = state.copyWith(livePingMs: live); - // 服务端权威 down(已 2-strike 去抖)→ 立即处理,不再等客户端连续失败。 if (serverDown) { logLine('Watchdog', 'server marked node ${node.code} down → unhealthy'); _stopWatchdog(); await _onNodeUnhealthy(); - return; - } - if (rtt > 0) { - _healthFails = 0; - return; - } - _healthFails++; - logLine('Watchdog', 'data-plane probe ${node.code} ${node.host}:${node.port} failed ($_healthFails/$_kHealthFailLimit)'); - if (_healthFails >= _kHealthFailLimit) { - _stopWatchdog(); - await _onNodeUnhealthy(); } } - /// 当前节点连续探测失败 / 被服务端判 down:智能 → 切到其他最优可用节点重连;手动 → 断开并提示。 + /// 所连节点被服务端判 down:智能 → 切到其他最优可用节点重连;手动 → 断开并提示。 Future _onNodeUnhealthy() async { final t = _ref.read(appTextProvider); final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider); @@ -299,8 +276,9 @@ class ConnectionController extends StateNotifier { return; } // 手动选定节点(或智能模式无其他可用节点):断开并提示,尊重用户选择、不自动换。 + // 提示走 _offNotice,由 _disconnect/_onKernelStatus 应用,避免被 kernel off 覆盖。 + _offNotice = t.nodeUnhealthyError; await _disconnect(); - if (mounted) state = ConnectionState(phase: VpnPhase.off, error: t.nodeUnhealthyError); } /// 选延迟最优、可用(status up)、非当前节点的 code;无则 null。 diff --git a/client/test/unit/connection_watchdog_test.dart b/client/test/unit/connection_watchdog_test.dart index 6a2ffb8..d3a126c 100644 --- a/client/test/unit/connection_watchdog_test.dart +++ b/client/test/unit/connection_watchdog_test.dart @@ -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> build() async => const [ @@ -62,7 +64,7 @@ class _StubNodes extends NodesNotifier { Future refresh() async {} } -// 当前最优节点 HK 被服务端判 down(status≠up),JP 仍可用 —— 用于验证信号③。 +// 当前最优节点 HK 被服务端判 down(status≠up),JP 仍可用。 class _StubNodesHKDown extends NodesNotifier { @override Future> 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 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 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(); }); }