feat(M4/M5): URLTest 自动选线 + Kill-switch + 弹性重连 [tsk_xAQhC1xuCd8x]

M4 URLTest 自动选线:
- ClashApiClient.getGroupDelay() — GET /group/<name>/delay 触发按需测速
- ClashApiClient.extractUrltestResults() — 从 /proxies 响应解析 URLTest
  组成员最新延迟,填充 stats 帧的 urltestResults 字段
- DesktopKernelProcess._startStatsPoll() — 每秒并行拉取连接统计 + URLTest
  延迟(_clashApi.getProxies()),URLTest 失败不影响主统计
- DesktopVpnBridge.selectOutbound(tag) — Clash API PUT /proxies/proxy,
  tag 可传节点 tag 或 urltest 组 tag(如 "auto-select")恢复自动
- DesktopVpnBridge.getActiveOutbound() — GET /proxies 读 proxy.now
- app/kernel/poc/reality_client.config.json.tmpl — 增加 urltest("auto-select")
  + selector("proxy") 出口组,route.final 改为 "proxy"

M5 Kill-switch + 弹性重连:
- DesktopVpnBridge.setKillSwitch({required bool on}) — 将 _killSwitchEnabled
  写入 TUN inbound strict_route 字段(applyKillSwitchToConfig);内核运行中
  触发静默重载(kill → start(lastConfig))
- DesktopVpnBridge.applyKillSwitchToConfig() — static,killSwitch=true ↔
  TUN strict_route=true,killSwitch=false ↔ strict_route=false
- 自动退避重连:kernel 崩溃推 error → _scheduleReconnect() 退避序列
  1s/2s/4s/8s/16s/30s(上限),stop() 取消,重连成功后重置计数器
- DesktopKernelProcess 意外退出清理 _clashApi(避免下次 spawn 残留)
- app/pangolin/test/killswitch_checklist.md — 三端故障注入验收清单
  (Desktop ,iOS/Android 欠账说明)

新增测试(client/test/bridge/desktop_vpn_bridge_m4m5_test.dart):
- M4: extractUrltestResults 解析/空/无 history 边界
- M4: getGroupDelay HTTP 请求格式验证
- M4: selectOutbound / getActiveOutbound Clash API 调用断言
- M5: applyKillSwitchToConfig 字段覆盖 + 无 TUN 边界
- M5: setKillSwitch 状态注入 + 同值幂等
- M5: 崩溃→error 不崩 / 首次重连在退避延迟内触发 / stop 后不再重连

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-16 00:11:09 +08:00
parent cadd527680
commit 83dd5369ab
5 changed files with 1123 additions and 16 deletions
+83 -2
View File
@@ -167,6 +167,70 @@ class ClashApiClient {
}
}
// ── GET /group/<name>/delay ───────────────────────────────────
// 触发一次 URLTest 延迟测试并返回结果 {tag: delayMs}。
// 注意:此接口会发起真实网络探测,耗时约 timeoutMs。
// 若只需读缓存延迟,使用 getProxies() + extractUrltestResults()。
Future<Map<String, int>> getGroupDelay(
String groupName, {
String testUrl = 'http://www.gstatic.com/generate_204',
int timeoutMs = 3000,
}) async {
final uri = Uri.parse('$baseUrl/group/$groupName/delay').replace(
queryParameters: {
'url': testUrl,
'timeout': timeoutMs.toString(),
},
);
final response = await _http
.get(uri, headers: _headers)
.timeout(Duration(milliseconds: timeoutMs + 2000));
if (response.statusCode != 200) {
throw HttpException('getGroupDelay($groupName): ${response.statusCode}');
}
final data = jsonDecode(response.body);
if (data is! Map) return {};
return data.cast<String, int>();
}
// ── 工具:从 /proxies 响应提取 URLTest 延迟列表 ──────────────
// 扫描所有类型为 URLTest 的出口组,提取成员节点的最新 history delay。
static List<UrltestResult> extractUrltestResults(
Map<String, dynamic> proxiesResponse) {
final proxies = proxiesResponse['proxies'];
if (proxies is! Map) return const [];
final results = <UrltestResult>[];
for (final entry in proxies.entries) {
final p = entry.value;
if (p is! Map) continue;
final type = (p['type'] as String?) ?? '';
// URLTest / Fallback 组都有 all 成员列表
if (type != 'URLTest' && type != 'Fallback') continue;
final all = p['all'];
if (all is! List) continue;
for (final memberTag in all) {
if (memberTag is! String) continue;
final member = proxies[memberTag];
if (member is! Map) continue;
final history = member['history'];
if (history is List && history.isNotEmpty) {
final last = history.last;
if (last is Map) {
final delay = (last['delay'] as num?)?.toInt() ?? -1;
results.add(UrltestResult(tag: memberTag, delayMs: delay));
}
}
}
}
return results;
}
void dispose() => _http.close();
}
@@ -243,6 +307,9 @@ class DesktopKernelProcess implements KernelProcess {
int _prevUpTotal = 0;
DateTime _prevPollTime = DateTime.now();
// 最新 URLTest 延迟列表(每次 stats 轮询时更新)
List<UrltestResult> _lastUrltestResults = const [];
final _statusCtrl = StreamController<VpnStatus>.broadcast();
final _logCtrl = StreamController<String>.broadcast();
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
@@ -327,6 +394,9 @@ class DesktopKernelProcess implements KernelProcess {
_running = false;
_statsTimer?.cancel();
_statsTimer = null;
// 清理 Clash API 客户端,确保下次 spawn() 重建干净的客户端
_clashApi?.dispose();
_clashApi = null;
_log('[kernel] unexpected exit: code=$code');
_emitStatus(VpnStatus.error);
}
@@ -406,6 +476,7 @@ class DesktopKernelProcess implements KernelProcess {
_prevDownTotal = 0;
_prevUpTotal = 0;
_prevPollTime = DateTime.now();
_lastUrltestResults = const [];
}
// ── 就绪等待 ─────────────────────────────────────────────────
@@ -435,7 +506,17 @@ class DesktopKernelProcess implements KernelProcess {
if (!_running || _statsCtrl.isClosed) return;
try {
final now = DateTime.now();
final data = await _clashApi!.getConnections();
// 并行拉取连接统计与 URLTest 延迟(Best-effortURLTest 失败不影响主统计)
final connFuture = _clashApi!.getConnections();
final proxiesFuture = _clashApi!.getProxies().then((p) {
_lastUrltestResults = ClashApiClient.extractUrltestResults(p);
}).catchError((_) {
// 读取失败静默跳过,保留上次缓存值
});
final data = await connFuture;
await proxiesFuture; // 等 URLTest 解析完再组帧
final downTotal =
(data['downloadTotal'] as num?)?.toInt() ?? _prevDownTotal;
@@ -462,7 +543,7 @@ class DesktopKernelProcess implements KernelProcess {
downloadBytes: downTotal,
uploadSpeed: upSpeed.clamp(0, double.infinity),
downloadSpeed: downSpeed.clamp(0, double.infinity),
urltestResults: const [], // URLTest 节点选优归 11G
urltestResults: _lastUrltestResults,
));
}
} catch (_) {