263 lines
9.1 KiB
Swift
263 lines
9.1 KiB
Swift
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 <ttys>` + `lsof -p <pids>` 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 main thread.
|
|
var onPortsUpdated: ((_ 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<PanelKey> = []
|
|
|
|
/// 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 <all_pids> -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<Int>] = [:]
|
|
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 }
|
|
DispatchQueue.main.async {
|
|
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<Int>] {
|
|
// `lsof -nP -a -p <pids> -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<Int>] = [:]
|
|
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[..<arrowIdx.lowerBound])
|
|
}
|
|
// Port is after the last colon.
|
|
if let colonIdx = name.lastIndex(of: ":") {
|
|
let portStr = name[name.index(after: colonIdx)...]
|
|
// Strip anything non-numeric.
|
|
let cleaned = portStr.prefix(while: \.isNumber)
|
|
if let port = Int(cleaned), port > 0, port <= 65535 {
|
|
result[pid, default: []].insert(port)
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
}
|