mac: show full command and kill controls for ports
This commit is contained in:
@@ -72,20 +72,111 @@ actor PortGuardian {
|
||||
}
|
||||
}
|
||||
|
||||
struct PortReport: Identifiable {
|
||||
enum Status {
|
||||
case ok(String)
|
||||
case missing(String)
|
||||
case interference(String, offenders: [ReportListener])
|
||||
}
|
||||
|
||||
let port: Int
|
||||
let expected: String
|
||||
let status: Status
|
||||
let listeners: [ReportListener]
|
||||
|
||||
var id: Int { self.port }
|
||||
|
||||
var offenders: [ReportListener] {
|
||||
if case let .interference(_, offenders) = self.status { return offenders }
|
||||
return []
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self.status {
|
||||
case let .ok(text): return text
|
||||
case let .missing(text): return text
|
||||
case let .interference(text, _): return text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func describe(port: Int) async -> Descriptor? {
|
||||
guard let listener = await self.listeners(on: port).first else { return nil }
|
||||
let path = Self.executablePath(for: listener.pid)
|
||||
return Descriptor(pid: listener.pid, command: listener.command, executablePath: path)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private struct Listener {
|
||||
let pid: Int32
|
||||
let command: String
|
||||
let fullCommand: String
|
||||
let user: String?
|
||||
}
|
||||
|
||||
struct ReportListener: Identifiable {
|
||||
let pid: Int32
|
||||
let command: String
|
||||
let fullCommand: String
|
||||
let user: String?
|
||||
let expected: Bool
|
||||
|
||||
var id: Int32 { self.pid }
|
||||
}
|
||||
|
||||
func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] {
|
||||
let ports = [18788, 18789]
|
||||
var reports: [PortReport] = []
|
||||
|
||||
for port in ports {
|
||||
let listeners = await self.listeners(on: port)
|
||||
let expectedDesc: String
|
||||
let okPredicate: (Listener) -> Bool
|
||||
|
||||
switch mode {
|
||||
case .remote:
|
||||
expectedDesc = "SSH tunnel to remote gateway"
|
||||
okPredicate = { $0.command.lowercased().contains("ssh") }
|
||||
case .local:
|
||||
expectedDesc = port == 18788
|
||||
? "Gateway webchat/static host"
|
||||
: "Gateway websocket (node/tsx)"
|
||||
okPredicate = { listener in
|
||||
let c = listener.command.lowercased()
|
||||
return c.contains("node") || c.contains("clawdis") || c.contains("tsx") || c.contains("pnpm") || c.contains("bun")
|
||||
}
|
||||
}
|
||||
|
||||
if listeners.isEmpty {
|
||||
let text = "Nothing is listening on \(port) (\(expectedDesc))."
|
||||
reports.append(.init(port: port, expected: expectedDesc, status: .missing(text), listeners: []))
|
||||
continue
|
||||
}
|
||||
|
||||
let reportListeners = listeners.map { listener in
|
||||
ReportListener(
|
||||
pid: listener.pid,
|
||||
command: listener.command,
|
||||
fullCommand: listener.fullCommand,
|
||||
user: listener.user,
|
||||
expected: okPredicate(listener))
|
||||
}
|
||||
|
||||
let offenders = reportListeners.filter { !$0.expected }
|
||||
if offenders.isEmpty {
|
||||
let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
let okText = "Port \(port) is served by \(list)."
|
||||
reports.append(.init(port: port, expected: expectedDesc, status: .ok(okText), listeners: reportListeners))
|
||||
} else {
|
||||
let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
let reason = "Port \(port) is held by \(list), expected \(expectedDesc)."
|
||||
reports.append(.init(port: port, expected: expectedDesc, status: .interference(reason, offenders: offenders), listeners: reportListeners))
|
||||
}
|
||||
}
|
||||
|
||||
return reports
|
||||
}
|
||||
|
||||
private func listeners(on port: Int) async -> [Listener] {
|
||||
let res = await ShellExecutor.run(
|
||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||
@@ -101,7 +192,8 @@ actor PortGuardian {
|
||||
|
||||
func flush() {
|
||||
if let pid = currentPid, let cmd = currentCmd {
|
||||
listeners.append(Listener(pid: pid, command: cmd, user: currentUser))
|
||||
let full = Self.readFullCommand(pid: pid) ?? cmd
|
||||
listeners.append(Listener(pid: pid, command: cmd, fullCommand: full, user: currentUser))
|
||||
}
|
||||
currentPid = nil
|
||||
currentCmd = nil
|
||||
@@ -127,6 +219,25 @@ actor PortGuardian {
|
||||
return listeners
|
||||
}
|
||||
|
||||
private static func readFullCommand(pid: Int32) -> String? {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/ps")
|
||||
proc.arguments = ["-p", "\(pid)", "-o", "command="]
|
||||
let pipe = Pipe()
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func executablePath(for pid: Int32) -> String? {
|
||||
#if canImport(Darwin)
|
||||
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
|
||||
@@ -137,7 +248,6 @@ actor PortGuardian {
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func kill(_ pid: Int32) async -> Bool {
|
||||
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
if term.ok { return true }
|
||||
|
||||
Reference in New Issue
Block a user