import Foundation import TsnetBridge enum TsnetError: Error { case newNodeFailed, notUp, needsAuthKey } /// 多 tsnet 节点注册表:按 Tailscale 连接 id 复用节点。 /// /// tsnet v1.102.0 官方支持一进程多 `tsnet.Server`(各自独立 netstack;dial 走具体 node 句柄、 /// 无全局路由,故重叠 100.x 不会误连——只要每个 host 始终经其绑定的那个 Node dial)。铁律不破: /// 仍是一个 Go runtime、一次 gomobile bind(全在 tsnetbridge 包)。 /// /// 策略:**懒启动**(用到才 Up)·**Up 后常驻**(不空闲 Down,M3 mosh 恢复依赖 netstack 存活)· /// **软上限 3**(超限 LRU 关闭无活跃会话的最久未用节点)·**in-flight 去重**(同连接并发 connect /// 绝不 double-Up 同一 stateDir——tsnet 无文件锁,同 Dir 双 Up 会损坏状态)。 /// /// 线程安全(`@unchecked Sendable` + 锁):Up/dial 阻塞,由 `TerminalSession` 在后台线程调用。 final class TsnetRegistry: @unchecked Sendable { static let shared = TsnetRegistry() private let lock = NSLock() private var nodes: [UUID: TsnetbridgeNode] = [:] private var upLocks: [UUID: NSLock] = [:] private var lastUsed: [UUID: Date] = [:] private var activeSessions: Set = [] private let maxLive = 3 private let stateBase: String init() { stateBase = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0] } /// 取(必要时懒启动)某连接的 tsnet 节点。阻塞,需后台线程调用。 /// `authKey` 仅在节点尚未注册(首次 Up)时需要;已持久化 state 的节点用 nil(走已存 node key)。 func node(connID: UUID, stateDirName: String, hostname: String, authKey: String?, timeoutMs: Int) throws -> TsnetbridgeNode { lock.lock() if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n } let upLock = upLocks[connID] ?? { let l = NSLock(); upLocks[connID] = l; return l }() lock.unlock() // 同连接串行 Up(in-flight 去重);不同连接可并发。 upLock.lock() defer { upLock.unlock() } lock.lock() if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n } lock.unlock() let dir = stateBase + "/" + stateDirName try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) guard let n = TsnetbridgeNewNode(dir, hostname) else { throw TsnetError.newNodeFailed } try n.up(withAuthKey: authKey ?? "", timeoutMs: timeoutMs) lock.lock() nodes[connID] = n lastUsed[connID] = Date() evictIfNeededLocked(keeping: connID) lock.unlock() return n } /// 为 mosh 建立 UDP relay(阻塞,后台线程调用)。节点必须已 Up。 func startMoshRelay(connID: UUID, host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay { lock.lock(); let n = nodes[connID]; lock.unlock() guard let n else { throw TsnetError.notUp } return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs) } /// M3:唤醒某连接的 tsnet 网络层(前台恢复,可能阻塞)。 func wakeUp(connID: UUID) { lock.lock(); let n = nodes[connID]; lock.unlock() n?.wakeUp() } /// 标记某连接是否有活跃会话(供 LRU 不驱逐在用节点)。 func markSessionActive(_ connID: UUID, _ active: Bool) { lock.lock() if active { activeSessions.insert(connID) } else { activeSessions.remove(connID) } lock.unlock() } /// 关闭并销毁某连接的节点(删连接 / LRU 驱逐)。 func close(_ connID: UUID) { lock.lock() let n = nodes[connID] nodes[connID] = nil; lastUsed[connID] = nil; activeSessions.remove(connID) lock.unlock() try? n?.close() } /// 超出软上限时,关闭无活跃会话、最久未用的节点(保护刚取到的 keeping)。 private func evictIfNeededLocked(keeping: UUID) { while nodes.count > maxLive { let candidates = nodes.keys .filter { $0 != keeping && !activeSessions.contains($0) } .sorted { (lastUsed[$0] ?? .distantPast) < (lastUsed[$1] ?? .distantPast) } guard let victim = candidates.first, let n = nodes[victim] else { break } nodes[victim] = nil; lastUsed[victim] = nil try? n.close() } } }