fix(client): 看门狗+延迟统一到数据面 IP TCP 探测 + 消费服务端 down 信号

修复:数据面挂了客户端无感。原看门狗探 generate_204,会被分流判 direct 走本地
出网假阳性,永不触发。改为三路融合判活,针对「实际所连节点」(_connectedNode,
非会随 ping 漂移的 effectiveNode):
① 客户端→数据口 TCP 握手(节点 host:443,TUN 内必 direct→直奔真实节点,不被分流糊弄);
② 延迟同源:①的 RTT 回填连接页延迟(ConnectionState.livePingMs),挂了掉成 —;
③ 服务端权威:刷 /v1/nodes,所连节点被判 down(dp_healthy=0/agent掉线/运维下线)即处理。
看门狗即时首测 + 周期 15s;连续 3 次握不上 / 服务端 down → 智能切节点 / 手动告警断开。
连接页延迟改读 livePingMs(删除内核 urltest 取数)。补信号③测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 21:27:49 +08:00
parent 23bf3077c8
commit 554708a092
3 changed files with 111 additions and 55 deletions
+3 -11
View File
@@ -2,7 +2,6 @@
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';
@@ -41,9 +40,9 @@ class ConnectPage extends ConsumerWidget {
}
final down = formatSpeed(stats?.downloadSpeed);
final up = formatSpeed(stats?.uploadSpeed);
// 延迟来源:连接后优先用内核 urltest 实测(代理真实 RTT);urltest 还没探过时
// 回退节点页 TCP 探针 ping,尽量不显示 —。都没有才 —。
var livePing = conn.phase == VpnPhase.on ? _bestUrltest(stats) : node.ping;
// 延迟来源:连接后用看门狗对数据面 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;
final pingLabel = livePing > 0 ? '${livePing}ms' : '';
final latencyValue = livePing > 0 ? '$livePing' : '';
@@ -208,13 +207,6 @@ class ConnectPage extends ConsumerWidget {
}
}
/// 内核 urltest 各出站中的最小正延迟(ms);无则 0。
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});
+69 -42
View File
@@ -6,7 +6,6 @@
// - 严禁乐观翻转:VpnPhase.on 必须由 bridge.statusStream 确认后才置。
// - 状态来源:bridge.statusStream(来自内核回调),非 Timer 模拟。
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -18,6 +17,7 @@ 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,7 +34,8 @@ enum VpnPhase { off, connecting, on }
// ── 连接状态快照 ──────────────────────────────────────────────────
class ConnectionState {
const ConnectionState({required this.phase, this.elapsed = Duration.zero, this.error});
const ConnectionState(
{required this.phase, this.elapsed = Duration.zero, this.error, this.livePingMs});
final VpnPhase phase;
final Duration elapsed;
@@ -42,60 +43,57 @@ class ConnectionState {
/// 连接失败原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。
final String? error;
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) =>
ConnectionState(phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed);
/// 连接期实测延迟(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,
);
@override
bool operator ==(Object other) =>
other is ConnectionState &&
other.phase == phase &&
other.elapsed == elapsed &&
other.error == error;
other.error == error &&
other.livePingMs == livePingMs;
@override
int get hashCode => Object.hash(phase, elapsed, error);
int get hashCode => Object.hash(phase, elapsed, error, livePingMs);
}
// ── 连通看门狗 ─────────────────────────────────────────────────────
//
// 已连接(本地 TUN 已起)≠ 远端节点数据面可用:节点 sing-box 崩了/数据口不通而
// agent 仍在线时,客户端会一直显示「已连接」但流量全失败。看门狗周期性经隧道做
// 连通性探测,连续失败即判当前节点不可用 → 智能选择自动切节点 / 手动选定的只断开提示
// 已连接(本地 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 变化自动飘到别的节点,会漏判当前节点已挂。
/// 连通探测接缝:true=经隧道能到外网。默认走海外 204 端点;测试可注入假实现。
typedef HealthProber = Future<bool> Function();
/// 数据面探测接缝:对节点数据口 (host, port=REALITY 443) 做 TCP 握手,返回 RTT(ms),
/// 0=不可达。默认走 latency_probe;测试可注入假实现。看门狗判活 + 连接页延迟两用,单一口径。
typedef DataPlaneProber = Future<int> Function(String host, int port);
const _kWatchdogInterval = Duration(seconds: 15);
const _kHealthFailLimit = 3; // 连续 N 次失败(≈45s)才判定不可用,防抖
/// 默认探测:GET 海外 generate_204(全局 TUN 下走隧道 → 真实检验代理出网)。
/// 任一端点 2xx/204 即视为通;多端点容错,避免单点误判。
Future<bool> defaultHealthProbe() async {
const urls = [
'https://www.gstatic.com/generate_204',
'https://cp.cloudflare.com/generate_204',
];
for (final u in urls) {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 4);
try {
final req = await client.getUrl(Uri.parse(u)).timeout(const Duration(seconds: 5));
final resp = await req.close().timeout(const Duration(seconds: 5));
await resp.drain<void>();
if (resp.statusCode >= 200 && resp.statusCode < 300) return true;
} catch (_) {
// 该端点不通,试下一个
} finally {
client.close(force: true);
}
}
return false;
}
const _kHealthFailLimit = 3; // 连续 N 次握不上(≈45s)才判定不可用,防抖
// ── 状态机 ───────────────────────────────────────────────────────
class ConnectionController extends StateNotifier<ConnectionState> {
ConnectionController(this._ref, this._bridge, {HealthProber? prober})
: _prober = prober ?? defaultHealthProbe,
ConnectionController(this._ref, this._bridge, {DataPlaneProber? prober})
: _prober = prober ?? probeLatency,
super(const ConnectionState(phase: VpnPhase.off)) {
// 订阅桥状态流:状态由内核事件驱动,严禁 UI 乐观翻转。
_statusSub = _bridge.statusStream.listen(_onKernelStatus);
@@ -103,13 +101,15 @@ class ConnectionController extends StateNotifier<ConnectionState> {
final Ref _ref;
final VpnBridge _bridge;
final HealthProber _prober;
final DataPlaneProber _prober;
StreamSubscription<VpnStatus>? _statusSub;
Timer? _elapsed;
Timer? _watchdog;
int _healthFails = 0;
bool _probing = false;
ConnectApi? _api;
// 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。
Node? _connectedNode;
// ── 公有 API ───────────────────────────────────────────────────
@@ -153,6 +153,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
return;
}
_connectedNode = node; // 锁定本次实际所连节点,供看门狗探测/判活
try {
final configJson = await _fetchConfigWithRefresh(node.uuid);
// bridge.start() 不阻塞至连接建立;on 状态由 statusStream 回调驱动。
@@ -230,6 +231,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_watchdog?.cancel();
_healthFails = 0;
_watchdog = Timer.periodic(_kWatchdogInterval, (_) => _checkHealth());
unawaited(_checkHealth()); // 立即测一次:尽快回填延迟 + 早发现数据面异常
}
void _stopWatchdog() {
@@ -241,26 +243,51 @@ class ConnectionController extends StateNotifier<ConnectionState> {
Future<void> _checkHealth() async {
if (_probing || !mounted || state.phase != VpnPhase.on) return;
// 实际所连节点(测试态直接 emit on 时未走 _connect → 回退 effectiveNode)。
final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider);
if (node.uuid.isEmpty) return;
_probing = true;
final ok = await _prober();
// 信号③:服务端权威健康——刷 /v1/nodes,看所连节点是否被判 down。
var serverDown = false;
try {
await _ref.read(nodesProvider.notifier).refresh();
final cur = (_ref.read(nodesProvider).valueOrNull ?? const <Node>[])
.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;
if (ok) {
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:智能 → 切到其他最优可用节点重连;手动 → 断开并提示。
Future<void> _onNodeUnhealthy() async {
final t = _ref.read(appTextProvider);
final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider);
final smart = _ref.read(selectedNodeCodeProvider) == kSmartNodeCode;
final altCode = smart ? _pickAlternativeCode(_ref.read(effectiveNodeProvider).code) : null;
final altCode = smart ? _pickAlternativeCode(node.code) : null;
if (smart && altCode != null) {
_ref.read(selectedNodeCodeProvider.notifier).select(altCode);
await _disconnect();
+39 -2
View File
@@ -62,6 +62,17 @@ class _StubNodes extends NodesNotifier {
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();
@@ -72,7 +83,8 @@ void main() {
connectApiFactoryProvider.overrideWithValue((_) => _FakeConnectApi()),
connectionProvider.overrideWith((ref) => ConnectionController(
ref, ref.watch(vpnBridgeProvider),
prober: () async => healthy,
// 数据面 TCP 探测:健康→返回 RTT 30ms,不健康→0(不可达)。
prober: (host, port) async => healthy ? 30 : 0,
)),
]);
@@ -108,7 +120,7 @@ void main() {
addTearDown(bridge.dispose);
addTearDown(c.dispose);
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
c.read(selectedNodeCodeProvider.notifier).state = 'HK'; // 手动选定 HK
c.read(selectedNodeCodeProvider.notifier).select('HK'); // 手动选定 HK
await driveOnAndProbe(tester, c, bridge);
expect(c.read(selectedNodeCodeProvider), 'HK', reason: '手动模式不应自动换节点');
final st = c.read(connectionProvider);
@@ -129,4 +141,29 @@ void main() {
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。
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,智能模式应切到其他可用节点');
});
}