vibetunnel/mac/VibeTunnel/Core/Utilities/PortConflictResolver.swift
2025-06-23 14:58:11 +02:00

556 lines
19 KiB
Swift

import Darwin.C
import Foundation
import OSLog
/// Information about a process that's using a port
struct ProcessDetails {
let pid: Int
let name: String
let path: String?
let parentPid: Int?
let bundleIdentifier: String?
/// Check if this is a VibeTunnel process
var isVibeTunnel: Bool {
if let bundleId = bundleIdentifier {
return bundleId.contains("vibetunnel") || bundleId.contains("VibeTunnel")
}
if let path {
return path.contains("VibeTunnel")
}
return name.contains("VibeTunnel")
}
/// Check if this is one of our managed servers
var isManagedServer: Bool {
// Direct vibetunnel binary
if name == "vibetunnel" || name.contains("vibetunnel") {
return true
}
// Node server with VibeTunnel in path
if name.contains("node") && (path?.contains("VibeTunnel") ?? false) {
return true
}
// Bun executable (our vibetunnel binary is a Bun executable)
if name.contains("bun") && (path?.contains("VibeTunnel") ?? false) {
return true
}
// Check if the path contains our bundle identifier
if let path, path.contains("sh.vibetunnel") {
return true
}
return false
}
}
/// Information about a port conflict
struct PortConflict {
let port: Int
let process: ProcessDetails
let rootProcess: ProcessDetails?
let suggestedAction: ConflictAction
let alternativePorts: [Int]
}
/// Suggested action for resolving a port conflict
enum ConflictAction {
case killOurInstance(pid: Int, processName: String)
case suggestAlternativePort
case reportExternalApp(name: String)
}
/// Resolves port conflicts and suggests remediation
@MainActor
final class PortConflictResolver {
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "PortConflictResolver")
static let shared = PortConflictResolver()
private init() {}
/// Check if a port is available by attempting to bind to it
func isPortAvailable(_ port: Int) async -> Bool {
// First check if any process is using it
if await detectConflict(on: port) != nil {
return false
}
// Then try to actually bind to the port
return await canBindToPort(port)
}
/// Attempt to bind to a port to verify it's truly available
func canBindToPort(_ port: Int) async -> Bool {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let sock = socket(AF_INET, SOCK_STREAM, 0)
guard sock >= 0 else {
self.logger.debug("Failed to create socket for port check")
continuation.resume(returning: false)
return
}
defer { close(sock) }
// Enable SO_REUSEADDR to handle TIME_WAIT state
var reuseAddr = 1
if setsockopt(
sock,
SOL_SOCKET,
SO_REUSEADDR,
&reuseAddr,
socklen_t(MemoryLayout.size(ofValue: reuseAddr))
) < 0 {
self.logger.debug("Failed to set SO_REUSEADDR: \(errno)")
}
// Set SO_REUSEPORT for better compatibility
var reusePort = 1
if setsockopt(
sock,
SOL_SOCKET,
SO_REUSEPORT,
&reusePort,
socklen_t(MemoryLayout.size(ofValue: reusePort))
) < 0 {
self.logger.debug("Failed to set SO_REUSEPORT: \(errno)")
}
// Try to bind
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(port).bigEndian
addr.sin_addr.s_addr = INADDR_ANY
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
bind(sock, sockPtr, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
if result == 0 {
self.logger.debug("Port \(port) is available (bind succeeded)")
continuation.resume(returning: true)
} else {
let error = errno
self.logger.debug("Port \(port) is not available (bind failed with errno \(error))")
continuation.resume(returning: false)
}
}
}
}
/// Detect what process is using a port
func detectConflict(on port: Int) async -> PortConflict? {
do {
// Use lsof to find process using the port
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
process.arguments = ["-i", ":\(port)", "-n", "-P", "-F"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
// Run the process on a background queue to avoid blocking main thread
let (exitCode, output) = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: (process.terminationStatus, output))
} catch {
continuation.resume(throwing: error)
}
}
}
guard exitCode == 0, !output.isEmpty else {
// Port is free
return nil
}
// Parse lsof output
if let processInfo = await parseLsofOutput(output) {
// Get root process
let rootProcess = await findRootProcess(for: processInfo)
// Find alternative ports
let alternatives = await findAvailablePorts(near: port, count: 3)
// Determine action
let action = determineAction(for: processInfo, rootProcess: rootProcess)
return PortConflict(
port: port,
process: processInfo,
rootProcess: rootProcess,
suggestedAction: action,
alternativePorts: alternatives
)
}
} catch {
logger.error("Failed to check port conflict: \(error)")
}
return nil
}
/// Kill a process and optionally its parent VibeTunnel instance
func resolveConflict(_ conflict: PortConflict) async throws {
switch conflict.suggestedAction {
case .killOurInstance(let pid, let processName):
logger.info("Killing conflicting process: \(processName) (PID: \(pid))")
// Kill the process
let killProcess = Process()
killProcess.executableURL = URL(fileURLWithPath: "/bin/kill")
killProcess.arguments = ["-9", "\(pid)"]
let exitCode = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
try killProcess.run()
killProcess.waitUntilExit()
continuation.resume(returning: killProcess.terminationStatus)
} catch {
continuation.resume(throwing: error)
}
}
}
if exitCode != 0 {
throw PortConflictError.failedToKillProcess(pid: pid)
}
// Wait with exponential backoff for port to be released
var retries = 0
let maxRetries = 5
while retries < maxRetries {
try await Task.sleep(for: .milliseconds(500 * UInt64(pow(2.0, Double(retries)))))
if await canBindToPort(conflict.port) {
logger.info("Port \(conflict.port) successfully released after \(retries + 1) retries")
break
}
retries += 1
if retries < maxRetries {
logger.debug("Port \(conflict.port) still not available, retry \(retries + 1)/\(maxRetries)")
}
}
if retries == maxRetries {
throw PortConflictError.portStillInUse(port: conflict.port)
}
case .suggestAlternativePort, .reportExternalApp:
// These require user action
throw PortConflictError.requiresUserAction
}
}
/// Force kill any process, regardless of type
func forceKillProcess(_ conflict: PortConflict) async throws {
logger.info("Force killing process: \(conflict.process.name) (PID: \(conflict.process.pid))")
// Kill the process
let killProcess = Process()
killProcess.executableURL = URL(fileURLWithPath: "/bin/kill")
killProcess.arguments = ["-9", "\(conflict.process.pid)"]
let exitCode = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
try killProcess.run()
killProcess.waitUntilExit()
continuation.resume(returning: killProcess.terminationStatus)
} catch {
continuation.resume(throwing: error)
}
}
}
if exitCode != 0 {
// Try with sudo if regular kill fails
logger.warning("Regular kill failed, attempting with elevated privileges")
throw PortConflictError.failedToKillProcess(pid: conflict.process.pid)
}
// Wait with exponential backoff for port to be released
var retries = 0
let maxRetries = 5
while retries < maxRetries {
try await Task.sleep(for: .milliseconds(500 * UInt64(pow(2.0, Double(retries)))))
if await canBindToPort(conflict.port) {
logger.info("Port \(conflict.port) successfully released after \(retries + 1) retries")
break
}
retries += 1
if retries < maxRetries {
logger.debug("Port \(conflict.port) still not available, retry \(retries + 1)/\(maxRetries)")
}
}
if retries == maxRetries {
throw PortConflictError.portStillInUse(port: conflict.port)
}
}
/// Find available ports near a given port
func findAvailablePorts(near port: Int, count: Int) async -> [Int] {
var availablePorts: [Int] = []
let range = max(1_024, port - 10)...(port + 100)
for candidatePort in range where candidatePort != port {
if await isPortAvailable(candidatePort) {
availablePorts.append(candidatePort)
if availablePorts.count >= count {
break
}
}
}
return availablePorts
}
// MARK: - Private Methods
private func parseLsofOutput(_ output: String) async -> ProcessDetails? {
var pid: Int?
var name: String?
var ppid: Int?
// Parse lsof field output format
let lines = output.components(separatedBy: "\n")
for line in lines {
if line.hasPrefix("p") {
pid = Int(line.dropFirst())
} else if line.hasPrefix("c") {
name = String(line.dropFirst())
} else if line.hasPrefix("R") {
ppid = Int(line.dropFirst())
}
}
guard let pid, let name else {
return nil
}
// Get additional process info
let path = await getProcessPath(pid: pid)
let bundleId = await getProcessBundleIdentifier(pid: pid)
return ProcessDetails(
pid: pid,
name: name,
path: path,
parentPid: ppid,
bundleIdentifier: bundleId
)
}
private func getProcessPath(pid: Int) async -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/ps")
process.arguments = ["-p", "\(pid)", "-o", "comm="]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
let output = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
}
}
}
return output.trimmingCharacters(in: .whitespacesAndNewlines)
} catch {
logger.debug("Failed to get process path: \(error)")
}
return nil
}
private func getProcessBundleIdentifier(pid: Int) async -> String? {
// Try to get bundle identifier using lsappinfo
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/lsappinfo")
process.arguments = ["info", "-only", "bundleid", "\(pid)"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
let output = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
}
}
}
// Parse bundleid from output
if let range = output.range(of: "\"", options: .backwards) {
let beforeQuote = output[..<range.lowerBound]
if let startRange = beforeQuote.range(of: "\"", options: .backwards) {
let bundleId = output[startRange.upperBound..<range.lowerBound]
return String(bundleId)
}
}
} catch {
logger.debug("Failed to get bundle identifier: \(error)")
}
return nil
}
private func findRootProcess(for process: ProcessDetails) async -> ProcessDetails? {
var current = process
var visited = Set<Int>()
while let parentPid = current.parentPid, parentPid > 1, !visited.contains(parentPid) {
visited.insert(current.pid)
// Get parent process info
if let parentInfo = await getProcessInfo(pid: parentPid) {
// If parent is VibeTunnel, it's our root
if parentInfo.isVibeTunnel {
return parentInfo
}
current = parentInfo
} else {
break
}
}
return nil
}
private func getProcessInfo(pid: Int) async -> ProcessDetails? {
// Get process info using ps
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/ps")
process.arguments = ["-p", "\(pid)", "-o", "pid=,ppid=,comm="]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
let output = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
}
}
}
let components = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .whitespaces)
.filter { !$0.isEmpty }
if components.count >= 3 {
let pid = Int(components[0]) ?? 0
let ppid = Int(components[1]) ?? 0
let name = components[2...].joined(separator: " ")
let path = await getProcessPath(pid: pid)
let bundleId = await getProcessBundleIdentifier(pid: pid)
return ProcessDetails(
pid: pid,
name: name,
path: path,
parentPid: ppid > 0 ? ppid : nil,
bundleIdentifier: bundleId
)
}
} catch {
logger.debug("Failed to get process info: \(error)")
}
return nil
}
private func determineAction(for process: ProcessDetails, rootProcess: ProcessDetails?) -> ConflictAction {
logger
.debug(
"Determining action for process: \(process.name) (PID: \(process.pid), Path: \(process.path ?? "unknown"))"
)
// If it's our managed server, kill it
if process.isManagedServer {
logger.info("Process identified as managed server: \(process.name)")
return .killOurInstance(pid: process.pid, processName: process.name)
}
// If root process is VibeTunnel, kill the whole app
if let root = rootProcess, root.isVibeTunnel {
logger.info("Root process identified as VibeTunnel: \(root.name)")
return .killOurInstance(pid: root.pid, processName: root.name)
}
// If the process itself is VibeTunnel
if process.isVibeTunnel {
logger.info("Process identified as VibeTunnel: \(process.name)")
return .killOurInstance(pid: process.pid, processName: process.name)
}
// Otherwise, it's an external app
logger.info("Process identified as external app: \(process.name)")
return .reportExternalApp(name: process.name)
}
}
// MARK: - Errors
enum PortConflictError: LocalizedError {
case failedToKillProcess(pid: Int)
case requiresUserAction
case portStillInUse(port: Int)
var errorDescription: String? {
switch self {
case .failedToKillProcess(let pid):
"Failed to terminate process with PID \(pid)"
case .requiresUserAction:
"This conflict requires user action to resolve"
case .portStillInUse(let port):
"Port \(port) is still in use after multiple attempts to free it"
}
}
}