import Foundation /// Batched port scanner that replaces per-shell `ps + lsof` scanning. /// /// Each shell sends a lightweight `report_tty` + `ports_kick` over the socket. /// PortScanner coalesces kicks across all panels, then runs a single /// `ps -t ` + `lsof -p ` covering every panel that needs scanning. /// /// Kick → coalesce → burst flow: /// 1. `kick()` adds panel to `pendingKicks` set /// 2. If no burst is active, starts a 200ms coalesce timer /// 3. Coalesce fires → snapshots pending set → starts burst of 6 scans /// 4. New kicks during burst merge into the active burst /// 5. After last scan, if new kicks arrived, start a new coalesce cycle final class PortScanner: @unchecked Sendable { static let shared = PortScanner() /// Callback delivers `(workspaceId, panelId, ports)` on the main actor. var onPortsUpdated: (@MainActor (_ workspaceId: UUID, _ panelId: UUID, _ ports: [Int]) -> Void)? // MARK: - State (all guarded by `queue`) private let queue = DispatchQueue(label: "com.cmux.port-scanner", qos: .utility) /// TTY name per (workspace, panel). private var ttyNames: [PanelKey: String] = [:] /// Panels that requested a scan since the last coalesce snapshot. private var pendingKicks: Set = [] /// Whether a burst sequence is currently running. private var burstActive = false /// Coalesce timer (200ms after first kick). private var coalesceTimer: DispatchSourceTimer? /// Burst scan offsets in seconds from the start of the burst. /// Each scan fires at this absolute offset; the recursive scheduler /// converts to relative delays between consecutive scans. private static let burstOffsets: [Double] = [0.5, 1.5, 3, 5, 7.5, 10] // MARK: - Public API struct PanelKey: Hashable { let workspaceId: UUID let panelId: UUID } func registerTTY(workspaceId: UUID, panelId: UUID, ttyName: String) { queue.async { [self] in let key = PanelKey(workspaceId: workspaceId, panelId: panelId) guard ttyNames[key] != ttyName else { return } ttyNames[key] = ttyName } } func unregisterPanel(workspaceId: UUID, panelId: UUID) { queue.async { [self] in let key = PanelKey(workspaceId: workspaceId, panelId: panelId) ttyNames.removeValue(forKey: key) pendingKicks.remove(key) } } func kick(workspaceId: UUID, panelId: UUID) { queue.async { [self] in let key = PanelKey(workspaceId: workspaceId, panelId: panelId) guard ttyNames[key] != nil else { return } pendingKicks.insert(key) if !burstActive { startCoalesce() } // If burst is active, the next scan iteration will pick up the new kick. } } // MARK: - Coalesce + Burst private func startCoalesce() { // Already on `queue`. coalesceTimer?.cancel() let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + 0.2) timer.setEventHandler { [weak self] in self?.coalesceTimerFired() } coalesceTimer = timer timer.resume() } private func coalesceTimerFired() { // Already on `queue`. coalesceTimer?.cancel() coalesceTimer = nil guard !pendingKicks.isEmpty else { return } burstActive = true runBurst(index: 0) } private func runBurst(index: Int, burstStart: DispatchTime? = nil) { // Already on `queue`. guard index < Self.burstOffsets.count else { burstActive = false // If new kicks arrived during the burst, start a new coalesce cycle. if !pendingKicks.isEmpty { startCoalesce() } return } let start = burstStart ?? .now() let deadline = start + Self.burstOffsets[index] queue.asyncAfter(deadline: deadline) { [weak self] in guard let self else { return } self.runScan() self.runBurst(index: index + 1, burstStart: start) } } // MARK: - Scan private func runScan() { // Already on `queue`. Snapshot which panels to scan and their TTYs. // We scan all registered panels, not just pending ones, since ports can // appear/disappear on any panel. let snapshot = ttyNames guard !snapshot.isEmpty else { pendingKicks.removeAll() return } // Clear pending kicks — they're accounted for in this scan. pendingKicks.removeAll() // Build TTY set (deduplicated). let uniqueTTYs = Set(snapshot.values) let ttyList = uniqueTTYs.joined(separator: ",") // 1. ps -t tty1,tty2,... -o pid=,tty= let pidToTTY = runPS(ttyList: ttyList) guard !pidToTTY.isEmpty else { // No processes on any TTY — clear ports for all panels. let results = snapshot.map { ($0.key, [Int]()) } deliverResults(results) return } // 2. lsof -nP -a -p -iTCP -sTCP:LISTEN -F pn let allPids = pidToTTY.keys.sorted().map(String.init).joined(separator: ",") let pidToPorts = runLsof(pidsCsv: allPids) // 3. Join: PID→TTY + PID→ports → TTY→ports var portsByTTY: [String: Set] = [:] for (pid, ports) in pidToPorts { guard let tty = pidToTTY[pid] else { continue } portsByTTY[tty, default: []].formUnion(ports) } // 4. Map to per-panel port lists. var results: [(PanelKey, [Int])] = [] for (key, tty) in snapshot { let ports = portsByTTY[tty].map { Array($0).sorted() } ?? [] results.append((key, ports)) } deliverResults(results) } private func deliverResults(_ results: [(PanelKey, [Int])]) { guard let callback = onPortsUpdated else { return } Task { @MainActor in for (key, ports) in results { callback(key.workspaceId, key.panelId, ports) } } } // MARK: - Process helpers private func runPS(ttyList: String) -> [Int: String] { // `ps -t tty1,tty2,... -o pid=,tty=` — targeted scan, much cheaper than -ax. let process = Process() let pipe = Pipe() process.executableURL = URL(fileURLWithPath: "/bin/ps") process.arguments = ["-t", ttyList, "-o", "pid=,tty="] process.standardOutput = pipe process.standardError = FileHandle.nullDevice do { try process.run() } catch { return [:] } let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() guard let output = String(data: data, encoding: .utf8) else { return [:] } var mapping: [Int: String] = [:] for line in output.split(separator: "\n") { let parts = line.split(whereSeparator: \.isWhitespace) guard parts.count >= 2, let pid = Int(parts[0]) else { continue } mapping[pid] = String(parts[1]) } return mapping } private func runLsof(pidsCsv: String) -> [Int: Set] { // `lsof -nP -a -p -iTCP -sTCP:LISTEN -F pn` let process = Process() let pipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") process.arguments = ["-nP", "-a", "-p", pidsCsv, "-iTCP", "-sTCP:LISTEN", "-Fpn"] process.standardOutput = pipe process.standardError = FileHandle.nullDevice do { try process.run() } catch { return [:] } let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() guard let output = String(data: data, encoding: .utf8) else { return [:] } // Parse lsof -F output: lines starting with 'p' = PID, 'n' = name (host:port). var result: [Int: Set] = [:] var currentPid: Int? for line in output.split(separator: "\n") { guard let first = line.first else { continue } switch first { case "p": currentPid = Int(line.dropFirst()) case "n": guard let pid = currentPid else { continue } var name = String(line.dropFirst()) // Strip remote endpoint if present. if let arrowIdx = name.range(of: "->") { name = String(name[.. 0, port <= 65535 { result[pid, default: []].insert(port) } } default: break } } return result } }