chore(client/macos): 清理延迟排查的诊断打点 + debug 注入(最终干净版)

延迟显示已修通。移除排查期的临时诊断:扩展侧 Log 客户端/klog 捕获、log.level=debug 注入、
diag 字段/setDiag;主 app StatsClient 每5帧的 dl/ul/urltest 打印;Dart 侧 [Stats]/[Ping] 打点。
保留全部修复:clash_api 注入取 urltest、sendProviderMessage 统计通道、/proxies history+active
缓存、共享广播流、_onStats 回退 effectiveNode、连接态跳过直连实测。

CURRENT_PROJECT_VERSION 52→53。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-30 18:03:30 +08:00
parent e00163e2d9
commit 7add0259b4
5 changed files with 23 additions and 90 deletions
+1 -10
View File
@@ -81,7 +81,6 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_statusSub = _bridge.statusStream.listen(_onKernelStatus);
// 订阅内核 stats:连接态把 urltest 实测回写连接节点的 ping,让连接页与节点列表同源一致。
_statsSub = _bridge.statsStream.listen(_onStats);
logLine('Stats', 'ConnectionController constructed, statsStream subscribed');
// 登出(主动退出 / 被强制下线 / 会话过期)→ 一律断开隧道:别让已登出/被踢的设备继续用
// 数据面。一处监听 auth 覆盖所有登出路径。token 续期(仍登录)不触发。
_authSub = _ref.listen<AuthState>(authProvider, (prev, next) {
@@ -245,24 +244,16 @@ class ConnectionController extends StateNotifier<ConnectionState> {
// 连接态:取内核 urltest 最小正延迟,回写连接节点 ping。全局 TUN 下 App 直接探测节点不可行,
// 内核(经 REALITY 真实直连数据面)是唯一能实测的;回写到 node.ping → 连接页/节点列表同源。
void _onStats(VpnStatsEvent e) {
// 无条件打点:确认 _onStats 是否被调用、当前 phase、urltest 是否到达 Dart 侧。
logLine('Stats',
'onStats phase=${state.phase.name} '
'urltest=[${e.urltestResults.map((r) => "${r.tag}:${r.delayMs}").join(",")}]');
if (state.phase != VpnPhase.on) return;
// 自动连接/重连到已在跑的隧道时不会走 _connect → _connectedNode 为 null;回退到
// effectiveNode(当前/智能所选),否则 urltest 实测无处回写、连接页延迟一直显示 —。
final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider);
final ds = e.urltestResults.where((r) => r.delayMs > 0).map((r) => r.delayMs).toList();
logLine('Stats',
'conn=${_connectedNode == null ? "null" : _connectedNode!.code} '
'eff=${node.code}/${node.uuid.isEmpty ? "EMPTY" : "ok"} ds=$ds');
if (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);
logLine('Stats', 'setLivePing node=${node.code} = ${best}ms');
_ref.read(nodesProvider.notifier).setLivePing(node.uuid, best);
}
+2 -13
View File
@@ -14,7 +14,6 @@ import '../services/api_config.dart';
import '../services/latency_probe.dart';
import 'auth_provider.dart';
import 'connection_provider.dart';
import '../bridge/log.dart';
// ── 节点列表 AsyncNotifier ────────────────────────────────────────
@@ -87,24 +86,14 @@ class NodesNotifier extends AsyncNotifier<List<Node>> {
/// 回写某节点的实测延迟(ms)。连接态由 ConnectionController 用内核 urltest 回填连接节点,
/// 让「连接页延迟」与「节点列表延迟」同源(都读 node.ping)、显示一致。ms<=0 忽略。
void setLivePing(String uuid, int ms) {
if (ms <= 0 || _disposed) {
logLine('Ping', 'setLivePing SKIP ms=$ms disposed=$_disposed');
return;
}
if (ms <= 0 || _disposed) return;
final list = state.valueOrNull;
if (list == null) {
logLine('Ping', 'setLivePing no-list');
return;
}
if (list == null) return;
var changed = false;
final updated = [
for (final n in list)
if (n.uuid == uuid && n.ping != ms) (() { changed = true; return n.copyWith(ping: ms); })() else n
];
final match = list.any((n) => n.uuid == uuid);
logLine('Ping',
'setLivePing ms=$ms match=$match changed=$changed '
'nodes=[${list.map((n) => "${n.code}:${n.ping}").join(",")}]');
if (changed) state = AsyncData(updated);
}
@@ -130,11 +130,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
experimental["cache_file"] = ["enabled": true]
}
root["experimental"] = experimental
// : debug , urltest (DNS//),
// StatsCollector Log
var logCfg = (root["log"] as? [String: Any]) ?? [:]
logCfg["level"] = "debug"
root["log"] = logCfg
guard let data = try? JSONSerialization.data(withJSONObject: root),
let out = String(data: data, encoding: .utf8) else { return configJSON }
return out
@@ -379,7 +374,6 @@ private func simpleError(_ msg: String) -> NSError {
final class StatsCollector: NSObject {
private let queue = DispatchQueue(label: "pangolin.stats.collector")
private var statusClient: LibboxCommandClient?
private var logClient: LibboxCommandClient?
private var started = false
private var clashTimer: DispatchSourceTimer?
@@ -403,7 +397,6 @@ final class StatsCollector: NSObject {
self.clashBase = "http://127.0.0.1:\(clashPort)"
self.clashSecret = clashSecret
self.statusClient = self.connectCommand(LibboxCommandStatus, "status")
self.logClient = self.connectCommand(LibboxCommandLog, "log") // urltest
self.started = true
self.startClashTimer()
}
@@ -428,9 +421,7 @@ final class StatsCollector: NSObject {
guard let self else { return }
self.clashTimer?.cancel(); self.clashTimer = nil
if let c = self.statusClient { try? c.disconnect() }
if let c = self.logClient { try? c.disconnect() }
self.statusClient = nil
self.logClient = nil
self.started = false
self.lock.lock()
self.latest = StatsCollector.zero
@@ -458,26 +449,19 @@ final class StatsCollector: NSObject {
// · /group/<name>/delay history,();
// history
private func pollClash() {
clashGet("/proxies", query: nil) { [weak self] obj, raw in
clashGet("/proxies") { [weak self] obj in
guard let self else { return }
let hist = StatsCollector.extractUrltestResults(obj)
let groups = StatsCollector.extractUrltestGroups(obj).filter { $0 != "GLOBAL" }
self.queue.async {
if !hist.isEmpty {
self.cacheUrltest(hist, src: "hist")
} else {
self.setDiag("hist= groups=\(groups) raw=\(raw.prefix(70))")
}
self.cacheUrltest(hist)
for name in groups {
self.clashGet("/group/\(name)/delay",
query: ["url": "http://www.gstatic.com/generate_204",
"timeout": "5000"]) { [weak self] dobj, draw in
"timeout": "5000"]) { [weak self] dobj in
guard let self else { return }
let active = StatsCollector.parseDelayMap(dobj)
self.queue.async {
if !active.isEmpty { self.cacheUrltest(active, src: "active") }
else { self.setDiag("active(\(name)) empty raw=\(draw.prefix(70))") }
}
self.queue.async { self.cacheUrltest(active) }
}
}
}
@@ -485,35 +469,27 @@ final class StatsCollector: NSObject {
}
/// ( history active ),/
private func cacheUrltest(_ list: [(tag: String, delayMs: Int)], src: String) {
private func cacheUrltest(_ list: [(tag: String, delayMs: Int)]) {
guard !list.isEmpty else { return }
lock.lock()
latestUrltest = list
latest["urltestResults"] = list.map { ["tag": $0.tag, "delayMs": $0.delayMs] }
latest["diag"] = "\(src)=\(list.map { "\($0.tag):\($0.delayMs)" }.joined(separator: ","))"
lock.unlock()
}
private func setDiag(_ s: String) {
lock.lock(); latest["diag"] = s; lock.unlock()
}
private func clashGet(_ path: String, query: [String: String]?,
completion: @escaping (Any?, String) -> Void) {
guard var comps = URLComponents(string: clashBase + path) else { completion(nil, "badurl"); return }
private func clashGet(_ path: String, query: [String: String]? = nil,
completion: @escaping (Any?) -> Void) {
guard var comps = URLComponents(string: clashBase + path) else { completion(nil); return }
if let query { comps.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } }
guard let url = comps.url else { completion(nil, "badurl"); return }
guard let url = comps.url else { completion(nil); return }
var req = URLRequest(url: url)
req.timeoutInterval = 8
if !clashSecret.isEmpty {
req.setValue("Bearer \(clashSecret)", forHTTPHeaderField: "Authorization")
}
URLSession.shared.dataTask(with: req) { data, resp, err in
if let err { completion(nil, "err:\(err.localizedDescription)"); return }
let code = (resp as? HTTPURLResponse)?.statusCode ?? -1
guard let data else { completion(nil, "http\(code):nodata"); return }
let raw = String(data: data, encoding: .utf8) ?? "<\(data.count)B>"
completion(try? JSONSerialization.jsonObject(with: data), "http\(code):\(raw)")
URLSession.shared.dataTask(with: req) { data, _, _ in
guard let data else { completion(nil); return }
completion(try? JSONSerialization.jsonObject(with: data))
}.resume()
}
@@ -573,19 +549,7 @@ extension StatsCollector: LibboxCommandClientHandlerProtocol {
func connected() {}
func disconnected(_ message: String?) {}
func clearLogs() {}
func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {
guard let it = messageList else { return }
while it.hasNext() {
guard let entry = it.next() else { break }
let msg = entry.message
let low = msg.lowercased()
// urltest (:DNS//)
guard low.contains("urltest") || low.contains("url-test")
|| low.contains("gstatic") || low.contains("generate_204") else { continue }
NSLog("[pangolin/klog] %@", msg)
lock.lock(); latest["klog"] = String(msg.prefix(180)); lock.unlock()
}
}
func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {}
func setDefaultLogLevel(_ level: Int32) {}
func initializeClashMode(_ modeList: (any LibboxStringIteratorProtocol)?, currentMode: String?) {}
func updateClashMode(_ newMode: String?) {}
@@ -593,7 +557,7 @@ extension StatsCollector: LibboxCommandClientHandlerProtocol {
func writeStatus(_ message: LibboxStatusMessage?) {
guard let m = message else { return }
// (), clash urltestResults / diag
// (), clash urltestResults
lock.lock()
latest["uploadBytes"] = m.uplinkTotal
latest["downloadBytes"] = m.downlinkTotal
@@ -659,7 +659,7 @@
baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -674,7 +674,7 @@
baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -689,7 +689,7 @@
baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -960,7 +960,7 @@
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -1010,7 +1010,7 @@
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
CODE_SIGN_IDENTITY = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -1059,7 +1059,7 @@
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
-11
View File
@@ -20,7 +20,6 @@ final class StatsClient: NSObject {
private let queue = DispatchQueue(label: "pangolin.stats.client")
private weak var session: NETunnelProviderSession?
private var timer: DispatchSourceTimer?
private var diagTick = 0 // : 5 urltest,
//
@@ -59,18 +58,8 @@ final class StatsClient: NSObject {
try session.sendProviderMessage(Data("stats".utf8)) { [weak self] resp in
guard let self, let resp, !resp.isEmpty else { return }
guard let obj = (try? JSONSerialization.jsonObject(with: resp)) as? [String: Any] else {
NSLog("[pangolin/stats] bad stats payload (%d bytes)", resp.count)
return
}
// : urltest ()
self.diagTick += 1
if self.diagTick % 5 == 0 {
let urls = (obj["urltestResults"] as? [[String: Any]]) ?? []
let pairs = urls.map { "\($0["tag"] ?? "?")=\($0["delayMs"] ?? -1)" }.joined(separator: ",")
NSLog("[pangolin/stats] dl=%@ ul=%@ urltest[%d]=%@ diag=%@ klog=%@",
"\(obj["downloadSpeed"] ?? 0)", "\(obj["uploadSpeed"] ?? 0)", urls.count, pairs,
"\(obj["diag"] ?? "-")", "\(obj["klog"] ?? "-")")
}
self.emit(obj)
}
} catch {