diff --git a/apps/macos/Sources/Clawdis/DebugActions.swift b/apps/macos/Sources/Clawdis/DebugActions.swift index 1099e478c..4581e1ed9 100644 --- a/apps/macos/Sources/Clawdis/DebugActions.swift +++ b/apps/macos/Sources/Clawdis/DebugActions.swift @@ -237,84 +237,12 @@ enum DebugActions { // MARK: - Port diagnostics - struct PortListener: Identifiable { - let pid: Int - let command: String - let user: String? - - var id: Int { self.pid } - } - - struct PortReport: Identifiable { - enum Status { - case ok(String) - case missing(String) - case interference(String, offenders: [PortListener]) - } - - let port: Int - let expected: String - let status: Status - - var id: Int { self.port } - - var offenders: [PortListener] { - 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 - } - } - } + typealias PortListener = PortGuardian.ReportListener + typealias PortReport = PortGuardian.PortReport static func checkGatewayPorts() async -> [PortReport] { let mode = CommandResolver.connectionSettings().mode - let ports = [18788, 18789] - var reports: [PortReport] = [] - - for port in ports { - let listeners = await self.listeners(on: port) - let expectedDesc: String - let okPredicate: (PortListener) -> 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 = { cmd in - let c = cmd.command.lowercased() - return c.contains("node") || c.contains("clawdis") || c.contains("tsx") - } - } - - if listeners.isEmpty { - let text = "Nothing is listening on \(port) (\(expectedDesc))." - reports.append(.init(port: port, expected: expectedDesc, status: .missing(text))) - continue - } - - let offenders = listeners.filter { !okPredicate($0) } - 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))) - } 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))) - } - } - - return reports + return await PortGuardian.shared.diagnose(mode: mode) } static func killProcess(_ pid: Int) async -> Result { @@ -326,47 +254,6 @@ enum DebugActions { return .failure(.message(detail)) } - private static func listeners(on port: Int) async -> [PortListener] { - let res = await ShellExecutor.run( - command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"], - cwd: nil, - env: nil, - timeout: 5) - guard res.ok, let data = res.payload, !data.isEmpty else { return [] } - let text = String(data: data, encoding: .utf8) ?? "" - var listeners: [PortListener] = [] - var currentPid: Int? - var currentCmd: String? - var currentUser: String? - - func flush() { - if let pid = currentPid, let cmd = currentCmd { - listeners.append(PortListener(pid: pid, command: cmd, user: currentUser)) - } - currentPid = nil - currentCmd = nil - currentUser = nil - } - - for line in text.split(separator: "\n") { - guard let prefix = line.first else { continue } - let value = String(line.dropFirst()) - switch prefix { - case "p": - flush() - currentPid = Int(value) - case "c": - currentCmd = value - case "u": - currentUser = value - default: - continue - } - } - flush() - return listeners - } - @MainActor static func openSessionStoreInCode() { let path = SessionLoader.defaultStorePath diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index a3ceef788..3c925fb74 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -21,6 +21,7 @@ struct DebugSettings: View { @State private var portCheckInFlight = false @State private var portReports: [DebugActions.PortReport] = [] @State private var portKillStatus: String? + @State private var pendingKill: DebugActions.PortListener? var body: some View { ScrollView(.vertical) { @@ -105,19 +106,28 @@ struct DebugSettings: View { Text(report.summary) .font(.caption) .foregroundStyle(.secondary) - if !report.offenders.isEmpty { - ForEach(report.offenders) { offender in + ForEach(report.listeners) { listener in + VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { - Text("\(offender.command) (\(offender.pid))") + Text("\(listener.command) (\(listener.pid))") .font(.caption.monospaced()) + .foregroundStyle(listener.expected ? .secondary : Color.red) .lineLimit(1) Spacer() Button("Kill") { - Task { await self.kill(offender.pid) } + self.requestKill(listener) } .buttonStyle(.bordered) } + Text(listener.fullCommand) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) } + .padding(6) + .background(Color.secondary.opacity(0.05)) + .cornerRadius(4) } } .padding(8) @@ -264,6 +274,16 @@ struct DebugSettings: View { await self.reloadModels() self.loadSessionStorePath() } + .alert(item: self.$pendingKill) { listener in + Alert( + title: Text("Kill \(listener.command) (\(listener.pid))?"), + message: Text("This process looks expected for the current mode. Kill anyway?"), + primaryButton: .destructive(Text("Kill")) { + Task { await self.killConfirmed(listener.pid) } + }, + secondaryButton: .cancel() + ) + } } @MainActor @@ -276,8 +296,17 @@ struct DebugSettings: View { } @MainActor - private func kill(_ pid: Int) async { - let result = await DebugActions.killProcess(pid) + private func requestKill(_ listener: DebugActions.PortListener) { + if listener.expected { + self.pendingKill = listener + } else { + Task { await self.killConfirmed(listener.pid) } + } + } + + @MainActor + private func killConfirmed(_ pid: Int32) async { + let result = await DebugActions.killProcess(Int(pid)) switch result { case .success: self.portKillStatus = "Sent kill to \(pid)." diff --git a/apps/macos/Sources/Clawdis/PortGuardian.swift b/apps/macos/Sources/Clawdis/PortGuardian.swift index cdb6be0ee..9e20e6b8f 100644 --- a/apps/macos/Sources/Clawdis/PortGuardian.swift +++ b/apps/macos/Sources/Clawdis/PortGuardian.swift @@ -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 }