fix(client): 看门狗后台唤醒不再误判节点死→假重连 (#18)
现象:app/Mac 后台或睡眠一段时间再打开,出现"重连过程"但 VPN 没掉。 根因:连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,挂起期 stats 停更、 _lastUrltestOk 冻住;唤醒时一算 >45s 即判上游死 → _onNodeUnhealthy 触发 disconnect+connect(智能模式还自动切节点),但隧道一直好好的。 修(connection_provider.dart 一处): - _onStats 记 _lastStatsAt(任意 stats 帧时刻);stats 出现 >10s 长间断(挂起/唤醒) 时把 _lastUrltestOk 前移到现在,给节点新的 45s 窗口自证 - 看门狗路径A 增加 statsLive 闸:仅在 stats 确实在流(距今 <10s)且 urltest 停 ≥45s 时才判节点死;stats 整体停(挂起未恢复)则跳过本轮 - 加可注入时钟 _now(默认 DateTime.now),使时间驱动的 path A 可单测 测试:connection_watchdog_test 新增两例 —— ① stats 在流但 urltest 停≥45s 仍正常判死 切节点(path A 未被削弱);② 挂起 60s 唤醒不假重连(无 statsLive 闸则会误切,真回归)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -71,12 +71,16 @@ class ConnectionState {
|
||||
const _kWatchdogInterval = Duration(seconds: 15);
|
||||
// urltest 超过此时长未成功(连接态)即判上游(节点数据面)死。须 > 原生 urltest 探测间隔。
|
||||
const _kUrltestStale = Duration(seconds: 45);
|
||||
// stats 帧在此时长内到过即视为「在流」。超过(app 挂起/唤醒未恢复)时看门狗路径 A 不判活,
|
||||
// 避免把 app 被挂起的空档错算成节点死。须 > 原生 stats 轮询间隔(~1s),留足余量。
|
||||
const _kStatsLive = Duration(seconds: 10);
|
||||
|
||||
// ── 状态机 ───────────────────────────────────────────────────────
|
||||
|
||||
class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
ConnectionController(this._ref, this._bridge)
|
||||
: super(const ConnectionState(phase: VpnPhase.off)) {
|
||||
ConnectionController(this._ref, this._bridge, {DateTime Function()? now})
|
||||
: _now = now ?? DateTime.now,
|
||||
super(const ConnectionState(phase: VpnPhase.off)) {
|
||||
// 订阅桥状态流:状态由内核事件驱动,严禁 UI 乐观翻转。
|
||||
_statusSub = _bridge.statusStream.listen(_onKernelStatus);
|
||||
// 订阅内核 stats:连接态把 urltest 实测回写连接节点的 ping,让连接页与节点列表同源一致。
|
||||
@@ -101,6 +105,11 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
bool _probing = false;
|
||||
// 最近一次 urltest 成功(经 proxy 真实可达)的时刻;路径 A 用其陈旧度判上游死。
|
||||
DateTime? _lastUrltestOk;
|
||||
// 最近收到任意 stats 帧的时刻;用于区分「节点死(stats 在流、urltest 停)」与
|
||||
// 「app 挂起/睡眠(stats 整体停)」,避免后台唤醒后看门狗用墙上时钟误判节点死→假重连。
|
||||
DateTime? _lastStatsAt;
|
||||
// 取「现在」的时钟(默认真实时钟,测试可注入以驱动 urltest 陈旧度/stats 在流判定)。
|
||||
final DateTime Function() _now;
|
||||
// 本次 off 的提示语(节点异常/自动切换);kernel off 与显式断开都读它,避免被互相覆盖。
|
||||
// 仅 _connect 开始时清空。用户主动断开则保持 null(无提示)。
|
||||
String? _offNotice;
|
||||
@@ -138,6 +147,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
_offNotice = null; // 新连接:清掉上次的中断提示
|
||||
_userDisconnect = false;
|
||||
_lastUrltestOk = null; // 重置 urltest 判活基准(连上后由 _onStats 首次成功置位)
|
||||
_lastStatsAt = null; // 重置 stats 在流基准(连上后由 _onStats 首帧置位)
|
||||
state = const ConnectionState(phase: VpnPhase.connecting);
|
||||
|
||||
final node = _ref.read(effectiveNodeProvider);
|
||||
@@ -245,6 +255,13 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
// 内核(经 REALITY 真实直连数据面)是唯一能实测的;回写到 node.ping → 连接页/节点列表同源。
|
||||
void _onStats(VpnStatsEvent e) {
|
||||
if (state.phase != VpnPhase.on) return;
|
||||
final now = _now();
|
||||
// stats 出现长间断(app 挂起/睡眠唤醒)→ 此前的 urltest 时刻已不可信(墙上时钟空转期间
|
||||
// 没有探测)。把判活基准前移到现在,给节点一个新的 _kUrltestStale 窗口自证,避免误判假重连。
|
||||
if (_lastStatsAt != null && now.difference(_lastStatsAt!) > _kStatsLive) {
|
||||
_lastUrltestOk = now;
|
||||
}
|
||||
_lastStatsAt = now;
|
||||
// 自动连接/重连到已在跑的隧道时不会走 _connect → _connectedNode 为 null;回退到
|
||||
// effectiveNode(当前/智能所选),否则 urltest 实测无处回写、连接页延迟一直显示 —。
|
||||
final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider);
|
||||
@@ -252,7 +269,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
final ds = e.urltestResults.where((r) => r.delayMs > 0).map((r) => r.delayMs);
|
||||
if (ds.isEmpty) return;
|
||||
// urltest 成功:经 proxy 出站(REALITY 到节点)真实可达 → 记录时刻(供路径 A 判活)+ 回写延迟。
|
||||
_lastUrltestOk = DateTime.now();
|
||||
_lastUrltestOk = now;
|
||||
final best = ds.reduce((a, b) => a < b ? a : b);
|
||||
_ref.read(nodesProvider.notifier).setLivePing(node.uuid, best);
|
||||
}
|
||||
@@ -296,8 +313,12 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
// 路径 A(跨端统一):urltest 曾成功过、但已超过 _kUrltestStale 未再成功 → 上游(节点
|
||||
// 数据面)死。urltest 经 proxy 出站探测,四端都有;不依赖原生端把「上游断」报成 off/error。
|
||||
final lastOk = _lastUrltestOk;
|
||||
if (lastOk != null && DateTime.now().difference(lastOk) > _kUrltestStale) {
|
||||
logLine('Watchdog', 'urltest stale ${DateTime.now().difference(lastOk).inSeconds}s → upstream dead → unhealthy');
|
||||
// stats 必须确实在流才判活:否则(app 刚挂起/唤醒、stats 还没恢复)看门狗会拿冻住的
|
||||
// 旧时刻误判。stats 未恢复时跳过本轮,等 _onStats 重置基准后再判。
|
||||
final now = _now();
|
||||
final statsLive = _lastStatsAt != null && now.difference(_lastStatsAt!) < _kStatsLive;
|
||||
if (statsLive && lastOk != null && now.difference(lastOk) > _kUrltestStale) {
|
||||
logLine('Watchdog', 'urltest stale ${now.difference(lastOk).inSeconds}s → upstream dead → unhealthy');
|
||||
_stopWatchdog();
|
||||
await _onNodeUnhealthy();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user