mac: show full command and kill controls for ports

This commit is contained in:
Peter Steinberger
2025-12-10 01:22:55 +01:00
parent 1820308ba2
commit 7871e705bf
3 changed files with 151 additions and 125 deletions

View File

@@ -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<Void, DebugActionError> {
@@ -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

View File

@@ -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)."

View File

@@ -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 }