Improve port killer

This commit is contained in:
Peter Steinberger 2025-06-22 10:28:15 +02:00
parent 41d602d6d9
commit 997b4ab416

View file

@ -1,5 +1,6 @@
import Foundation import Foundation
import OSLog import OSLog
import Darwin
/// Information about a process that's using a port /// Information about a process that's using a port
struct ProcessDetails { struct ProcessDetails {
@ -67,10 +68,64 @@ final class PortConflictResolver {
private init() {} private init() {}
/// Check if a port is available /// Check if a port is available by attempting to bind to it
func isPortAvailable(_ port: Int) async -> Bool { func isPortAvailable(_ port: Int) async -> Bool {
let result = await detectConflict(on: port) // First check if any process is using it
return result == nil if let _ = await detectConflict(on: port) {
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 {
return 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 /// Detect what process is using a port
@ -85,21 +140,30 @@ final class PortConflictResolver {
process.standardOutput = pipe process.standardOutput = pipe
process.standardError = 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() try process.run()
process.waitUntilExit() process.waitUntilExit()
guard process.terminationStatus == 0 else { 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 // Port is free
return nil return nil
} }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let output = String(data: data, encoding: .utf8), !output.isEmpty else {
return nil
}
// Parse lsof output // Parse lsof output
if let processInfo = parseLsofOutput(output) { if let processInfo = await parseLsofOutput(output) {
// Get root process // Get root process
let rootProcess = await findRootProcess(for: processInfo) let rootProcess = await findRootProcess(for: processInfo)
@ -135,15 +199,43 @@ final class PortConflictResolver {
killProcess.executableURL = URL(fileURLWithPath: "/bin/kill") killProcess.executableURL = URL(fileURLWithPath: "/bin/kill")
killProcess.arguments = ["-9", "\(pid)"] killProcess.arguments = ["-9", "\(pid)"]
let exitCode = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
try killProcess.run() try killProcess.run()
killProcess.waitUntilExit() killProcess.waitUntilExit()
continuation.resume(returning: killProcess.terminationStatus)
} catch {
continuation.resume(throwing: error)
}
}
}
if killProcess.terminationStatus != 0 { if exitCode != 0 {
throw PortConflictError.failedToKillProcess(pid: pid) throw PortConflictError.failedToKillProcess(pid: pid)
} }
// Wait a moment for port to be released // Wait with exponential backoff for port to be released
try await Task.sleep(for: .milliseconds(500)) 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: case .suggestAlternativePort, .reportExternalApp:
// These require user action // These require user action
@ -160,17 +252,45 @@ final class PortConflictResolver {
killProcess.executableURL = URL(fileURLWithPath: "/bin/kill") killProcess.executableURL = URL(fileURLWithPath: "/bin/kill")
killProcess.arguments = ["-9", "\(conflict.process.pid)"] killProcess.arguments = ["-9", "\(conflict.process.pid)"]
let exitCode = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
try killProcess.run() try killProcess.run()
killProcess.waitUntilExit() killProcess.waitUntilExit()
continuation.resume(returning: killProcess.terminationStatus)
} catch {
continuation.resume(throwing: error)
}
}
}
if killProcess.terminationStatus != 0 { if exitCode != 0 {
// Try with sudo if regular kill fails // Try with sudo if regular kill fails
logger.warning("Regular kill failed, attempting with elevated privileges") logger.warning("Regular kill failed, attempting with elevated privileges")
throw PortConflictError.failedToKillProcess(pid: conflict.process.pid) throw PortConflictError.failedToKillProcess(pid: conflict.process.pid)
} }
// Wait a moment for port to be released // Wait with exponential backoff for port to be released
try await Task.sleep(for: .milliseconds(500)) 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 /// Find available ports near a given port
@ -192,7 +312,7 @@ final class PortConflictResolver {
// MARK: - Private Methods // MARK: - Private Methods
private func parseLsofOutput(_ output: String) -> ProcessDetails? { private func parseLsofOutput(_ output: String) async -> ProcessDetails? {
var pid: Int? var pid: Int?
var name: String? var name: String?
var ppid: Int? var ppid: Int?
@ -214,8 +334,8 @@ final class PortConflictResolver {
} }
// Get additional process info // Get additional process info
let path = getProcessPath(pid: pid) let path = await getProcessPath(pid: pid)
let bundleId = getProcessBundleIdentifier(pid: pid) let bundleId = await getProcessBundleIdentifier(pid: pid)
return ProcessDetails( return ProcessDetails(
pid: pid, pid: pid,
@ -226,7 +346,7 @@ final class PortConflictResolver {
) )
} }
private func getProcessPath(pid: Int) -> String? { private func getProcessPath(pid: Int) async -> String? {
let process = Process() let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/ps") process.executableURL = URL(fileURLWithPath: "/bin/ps")
process.arguments = ["-p", "\(pid)", "-o", "comm="] process.arguments = ["-p", "\(pid)", "-o", "comm="]
@ -235,14 +355,23 @@ final class PortConflictResolver {
process.standardOutput = pipe process.standardOutput = pipe
process.standardError = Pipe() process.standardError = Pipe()
do {
let output = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do { do {
try process.run() try process.run()
process.waitUntilExit() process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8) { let output = String(data: data, encoding: .utf8) ?? ""
return output.trimmingCharacters(in: .whitespacesAndNewlines) continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
} }
}
}
return output.trimmingCharacters(in: .whitespacesAndNewlines)
} catch { } catch {
logger.debug("Failed to get process path: \(error)") logger.debug("Failed to get process path: \(error)")
} }
@ -250,7 +379,7 @@ final class PortConflictResolver {
return nil return nil
} }
private func getProcessBundleIdentifier(pid: Int) -> String? { private func getProcessBundleIdentifier(pid: Int) async -> String? {
// Try to get bundle identifier using lsappinfo // Try to get bundle identifier using lsappinfo
let process = Process() let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/lsappinfo") process.executableURL = URL(fileURLWithPath: "/usr/bin/lsappinfo")
@ -260,12 +389,22 @@ final class PortConflictResolver {
process.standardOutput = pipe process.standardOutput = pipe
process.standardError = Pipe() process.standardError = Pipe()
do {
let output = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do { do {
try process.run() try process.run()
process.waitUntilExit() process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8) { let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
}
}
}
// Parse bundleid from output // Parse bundleid from output
if let range = output.range(of: "\"", options: .backwards) { if let range = output.range(of: "\"", options: .backwards) {
let beforeQuote = output[..<range.lowerBound] let beforeQuote = output[..<range.lowerBound]
@ -274,7 +413,6 @@ final class PortConflictResolver {
return String(bundleId) return String(bundleId)
} }
} }
}
} catch { } catch {
logger.debug("Failed to get bundle identifier: \(error)") logger.debug("Failed to get bundle identifier: \(error)")
} }
@ -314,12 +452,22 @@ final class PortConflictResolver {
process.standardOutput = pipe process.standardOutput = pipe
process.standardError = Pipe() process.standardError = Pipe()
do {
let output = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do { do {
try process.run() try process.run()
process.waitUntilExit() process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8) { let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
}
}
}
let components = output.trimmingCharacters(in: .whitespacesAndNewlines) let components = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .whitespaces) .components(separatedBy: .whitespaces)
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
@ -328,8 +476,8 @@ final class PortConflictResolver {
let pid = Int(components[0]) ?? 0 let pid = Int(components[0]) ?? 0
let ppid = Int(components[1]) ?? 0 let ppid = Int(components[1]) ?? 0
let name = components[2...].joined(separator: " ") let name = components[2...].joined(separator: " ")
let path = getProcessPath(pid: pid) let path = await getProcessPath(pid: pid)
let bundleId = getProcessBundleIdentifier(pid: pid) let bundleId = await getProcessBundleIdentifier(pid: pid)
return ProcessDetails( return ProcessDetails(
pid: pid, pid: pid,
@ -339,7 +487,6 @@ final class PortConflictResolver {
bundleIdentifier: bundleId bundleIdentifier: bundleId
) )
} }
}
} catch { } catch {
logger.debug("Failed to get process info: \(error)") logger.debug("Failed to get process info: \(error)")
} }
@ -382,6 +529,7 @@ final class PortConflictResolver {
enum PortConflictError: LocalizedError { enum PortConflictError: LocalizedError {
case failedToKillProcess(pid: Int) case failedToKillProcess(pid: Int)
case requiresUserAction case requiresUserAction
case portStillInUse(port: Int)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@ -389,6 +537,8 @@ enum PortConflictError: LocalizedError {
"Failed to terminate process with PID \(pid)" "Failed to terminate process with PID \(pid)"
case .requiresUserAction: case .requiresUserAction:
"This conflict requires user action to resolve" "This conflict requires user action to resolve"
case .portStillInUse(let port):
"Port \(port) is still in use after multiple attempts to free it"
} }
} }
} }