diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index da31aa8..6eb2272 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -57,17 +57,20 @@ class ConnectionState { // ── 连通看门狗 ───────────────────────────────────────────────────── // -// 已连接(本地 TUN 已起)≠ 远端节点数据面可用。两条可靠路径发现节点异常: -// A. 意外内核掉线:节点数据面死 → REALITY 断 → 内核自报 off/error。凡「非用户主动」 -// 的掉线一律判为节点异常 → 置 error「节点异常,连接已中断」+ 刷节点列表(让 down 浮现)。 -// 这是节点真死时最先、最可靠的信号。见 _onKernelStatus。 +// 已连接(本地 TUN 已起)≠ 远端节点数据面可用。三条路径发现节点异常,全平台一致: +// A. urltest 陈旧(跨端统一主路径):内核 urltest 经 proxy 出站(REALITY→节点)探测, +// 节点死则探测失败、urltest 停更。看门狗发现「上次 urltest 成功超过 _kUrltestStale」 +// 即判上游死 → _onNodeUnhealthy。urltest 四端都有(stats),不依赖各原生端的状态上报。 +// A'. 意外内核掉线:原生端真把「上游断」上报成 off/error 时(桌面子进程确认有),走 +// _onKernelStatus 的非用户主动 off → 节点异常提示。属 A 的快路径,有则更快、无则靠 A 兜底。 // B. 服务端权威 down:看门狗连接态周期刷 /v1/nodes,所连节点被判 down(dp_healthy=0 / // agent 掉线 / 运维下线)即处理。覆盖「本地 TUN 还没断、但节点已被判不可用」。 -// 注:不再做客户端 TCP 探数据口——全局 TUN 下 App 的 socket 被本地 TUN 当场接住(假 1ms), -// 测不到真节点。延迟改由内核 urltest(握着到数据面真实直连)给,见 connect_page。 -// 判活针对「实际所连节点」(_connectedNode),非会随 ping 漂移的 effectiveNode。 +// 注:不做客户端 TCP 探数据口——全局 TUN 下 App 的 socket 被本地 TUN 当场接住(假 1ms), +// 测不到真节点。判活针对「实际所连节点」(_connectedNode),非会随 ping 漂移的 effectiveNode。 const _kWatchdogInterval = Duration(seconds: 15); +// urltest 超过此时长未成功(连接态)即判上游(节点数据面)死。须 > 原生 urltest 探测间隔。 +const _kUrltestStale = Duration(seconds: 45); // ── 状态机 ─────────────────────────────────────────────────────── @@ -87,6 +90,8 @@ class ConnectionController extends StateNotifier { Timer? _elapsed; Timer? _watchdog; bool _probing = false; + // 最近一次 urltest 成功(经 proxy 真实可达)的时刻;路径 A 用其陈旧度判上游死。 + DateTime? _lastUrltestOk; // 本次 off 的提示语(节点异常/自动切换);kernel off 与显式断开都读它,避免被互相覆盖。 // 仅 _connect 开始时清空。用户主动断开则保持 null(无提示)。 String? _offNotice; @@ -123,6 +128,7 @@ class ConnectionController extends StateNotifier { Future _connect() async { _offNotice = null; // 新连接:清掉上次的中断提示 _userDisconnect = false; + _lastUrltestOk = null; // 重置 urltest 判活基准(连上后由 _onStats 首次成功置位) state = const ConnectionState(phase: VpnPhase.connecting); final node = _ref.read(effectiveNodeProvider); @@ -233,6 +239,8 @@ class ConnectionController extends StateNotifier { if (node == null || node.uuid.isEmpty) return; final ds = e.urltestResults.where((r) => r.delayMs > 0).map((r) => r.delayMs); if (ds.isEmpty) return; + // urltest 成功:经 proxy 出站(REALITY 到节点)真实可达 → 记录时刻(供路径 A 判活)+ 回写延迟。 + _lastUrltestOk = DateTime.now(); final best = ds.reduce((a, b) => a < b ? a : b); _ref.read(nodesProvider.notifier).setLivePing(node.uuid, best); } @@ -271,6 +279,15 @@ class ConnectionController extends StateNotifier { logLine('Watchdog', 'server marked node ${node.code} down → unhealthy'); _stopWatchdog(); await _onNodeUnhealthy(); + return; + } + // 路径 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'); + _stopWatchdog(); + await _onNodeUnhealthy(); } } diff --git a/client/lib/state/nodes_provider.dart b/client/lib/state/nodes_provider.dart index 44e1071..6292b3a 100644 --- a/client/lib/state/nodes_provider.dart +++ b/client/lib/state/nodes_provider.dart @@ -16,6 +16,11 @@ import 'auth_provider.dart'; // ── 节点列表 AsyncNotifier ──────────────────────────────────────── +/// /v1/nodes 返回 401:access token 过期。供 _fetchWithRefresh 捕获后续期重试。 +class _NodesUnauthorized implements Exception { + const _NodesUnauthorized(); +} + class NodesNotifier extends AsyncNotifier> { bool _disposed = false; DateTime? _lastFetch; @@ -28,10 +33,15 @@ class NodesNotifier extends AsyncNotifier> { ref.onDispose(() => _disposed = true); final auth = ref.watch(authProvider); if (!auth.isLoggedIn) return const []; - final list = await _fetchNodes(auth.accessToken!); - _lastFetch = DateTime.now(); - unawaited(_measure(list)); - return list; + try { + final list = await _fetchWithRefresh(); + _lastFetch = DateTime.now(); + unawaited(_measure(list)); + return list; + } catch (_) { + // 首拉失败(网络/鉴权):返回空列表而非抛错;鉴权真过期由 authProvider 登出跳登录页。 + return const []; + } } /// 刷新节点列表(下拉/按钮/进页调用)。10s 限流;不清空当前列表(后台拉、好了再换), @@ -48,13 +58,14 @@ class NodesNotifier extends AsyncNotifier> { final result = await AsyncValue.guard(() async { // ping 是客户端实测(direct TCP / 连接态 urltest 回写),服务端列表不带它(渲染为 0)。 // 刷新时保留已有 ping,否则会把刚测/回写的延迟清成 0 → 连接页延迟闪成 —。 - final list = _mergePings(await _fetchNodes(auth.accessToken!)); + final list = _mergePings(await _fetchWithRefresh()); _lastFetch = DateTime.now(); unawaited(_measure(list)); return list; }); if (_disposed) return; - if (result.hasValue) state = result; // 成功才换;失败保留旧列表 + // 仅成功才换列表;失败(401/5xx/网络)保留旧列表——绝不因一次刷新失败把节点清空。 + if (result.hasValue) state = result; } /// 用当前 state 里各节点已有的 ping 回填新拉到的列表(按 uuid;已有>0 才覆盖占位 0)。 @@ -98,13 +109,30 @@ class NodesNotifier extends AsyncNotifier> { state = AsyncData(updated); } + /// 拉节点;401 时用 refresh token 续期重试一次(自愈),续期失败由 authProvider 登出。 + /// 非 200 一律抛异常 → 调用方(refresh)保留旧列表,不把列表清空。 + Future> _fetchWithRefresh() async { + final token = ref.read(authProvider).accessToken ?? ''; + try { + return await _fetchNodes(token); + } on _NodesUnauthorized { + final ok = await ref.read(authProvider.notifier).refresh(); + if (!ok) rethrow; // 续期也失败 = 真过期,交给 authProvider 触发登出 + return await _fetchNodes(ref.read(authProvider).accessToken ?? ''); + } + } + static Future> _fetchNodes(String accessToken) async { final uri = Uri.parse('$kApiBaseUrl/v1/nodes'); final resp = await http.get(uri, headers: { 'Authorization': 'Bearer $accessToken', }).timeout(const Duration(seconds: 10)); - if (resp.statusCode != 200) return const []; + // 非 200 抛异常(401 单独类型,供续期重试):绝不返回空列表覆盖掉已有节点。 + if (resp.statusCode == 401) throw const _NodesUnauthorized(); + if (resp.statusCode != 200) { + throw Exception('nodes fetch ${resp.statusCode}'); + } final body = jsonDecode(resp.body) as Map; final rawList = body['nodes'] as List? ?? [];