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:
wangjia
2026-06-29 23:21:09 +08:00
parent cd143af248
commit d9378b6729
4 changed files with 142 additions and 152 deletions
+14 -4
View File
@@ -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});
+13
View File
@@ -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<NodesPage> {
String _q = '';
bool _refreshing = false;
Timer? _poll;
@override
void initState() {
@@ -35,6 +38,16 @@ class _NodesPageState extends ConsumerState<NodesPage> {
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<void> _refresh() async {
+44 -66
View File
@@ -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<int> 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<ConnectionState> {
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<VpnStatus>? _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<ConnectionState> {
case VpnPhase.off:
_connect();
case VpnPhase.on:
_userDisconnect = true; // 用户主动断开:其 kernel off 不当作节点异常
_disconnect();
case VpnPhase.connecting:
break; // 握手进行中,不响应
@@ -135,6 +118,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
// ── 内部 ─────────────────────────────────────────────────────
Future<void> _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<ConnectionState> {
Future<void> _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<ConnectionState> {
_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<ConnectionState> {
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<ConnectionState> {
.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<void> _onNodeUnhealthy() async {
final t = _ref.read(appTextProvider);
final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider);
@@ -299,8 +276,9 @@ class ConnectionController extends StateNotifier<ConnectionState> {
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。