mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
369 lines
12 KiB
Swift
369 lines
12 KiB
Swift
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 {
|
|
name == "vibetunnel" || name.contains("node") && (path?.contains("VibeTunnel") ?? 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
|
|
func isPortAvailable(_ port: Int) async -> Bool {
|
|
let result = await detectConflict(on: port)
|
|
return result == nil
|
|
}
|
|
|
|
/// 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()
|
|
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
guard process.terminationStatus == 0 else {
|
|
// Port is free
|
|
return nil
|
|
}
|
|
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
guard let output = String(data: data, encoding: .utf8), !output.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
// Parse lsof output
|
|
if let processInfo = 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)"]
|
|
|
|
try killProcess.run()
|
|
killProcess.waitUntilExit()
|
|
|
|
if killProcess.terminationStatus != 0 {
|
|
throw PortConflictError.failedToKillProcess(pid: pid)
|
|
}
|
|
|
|
// Wait a moment for port to be released
|
|
try await Task.sleep(for: .milliseconds(500))
|
|
|
|
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)"]
|
|
|
|
try killProcess.run()
|
|
killProcess.waitUntilExit()
|
|
|
|
if killProcess.terminationStatus != 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 a moment for port to be released
|
|
try await Task.sleep(for: .milliseconds(500))
|
|
}
|
|
|
|
/// 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) -> 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 = getProcessPath(pid: pid)
|
|
let bundleId = getProcessBundleIdentifier(pid: pid)
|
|
|
|
return ProcessDetails(
|
|
pid: pid,
|
|
name: name,
|
|
path: path,
|
|
parentPid: ppid,
|
|
bundleIdentifier: bundleId
|
|
)
|
|
}
|
|
|
|
private func getProcessPath(pid: Int) -> 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 {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
if let output = String(data: data, encoding: .utf8) {
|
|
return output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
} catch {
|
|
logger.debug("Failed to get process path: \(error)")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func getProcessBundleIdentifier(pid: Int) -> 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 {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
if let output = String(data: data, encoding: .utf8) {
|
|
// 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 {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
if let output = String(data: data, encoding: .utf8) {
|
|
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 = getProcessPath(pid: pid)
|
|
let bundleId = 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 {
|
|
// If it's our managed server, kill it
|
|
if process.isManagedServer {
|
|
return .killOurInstance(pid: process.pid, processName: process.name)
|
|
}
|
|
|
|
// If root process is VibeTunnel, kill the whole app
|
|
if let root = rootProcess, root.isVibeTunnel {
|
|
return .killOurInstance(pid: root.pid, processName: root.name)
|
|
}
|
|
|
|
// If the process itself is VibeTunnel
|
|
if process.isVibeTunnel {
|
|
return .killOurInstance(pid: process.pid, processName: process.name)
|
|
}
|
|
|
|
// Otherwise, it's an external app
|
|
return .reportExternalApp(name: process.name)
|
|
}
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
enum PortConflictError: LocalizedError {
|
|
case failedToKillProcess(pid: Int)
|
|
case requiresUserAction
|
|
|
|
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"
|
|
}
|
|
}
|
|
}
|