mac: show full command and kill controls for ports
parent
1820308ba2
commit
7871e705bf
|
|
@ -237,84 +237,12 @@ enum DebugActions {
|
||||||
|
|
||||||
// MARK: - Port diagnostics
|
// MARK: - Port diagnostics
|
||||||
|
|
||||||
struct PortListener: Identifiable {
|
typealias PortListener = PortGuardian.ReportListener
|
||||||
let pid: Int
|
typealias PortReport = PortGuardian.PortReport
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func checkGatewayPorts() async -> [PortReport] {
|
static func checkGatewayPorts() async -> [PortReport] {
|
||||||
let mode = CommandResolver.connectionSettings().mode
|
let mode = CommandResolver.connectionSettings().mode
|
||||||
let ports = [18788, 18789]
|
return await PortGuardian.shared.diagnose(mode: mode)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func killProcess(_ pid: Int) async -> Result<Void, DebugActionError> {
|
static func killProcess(_ pid: Int) async -> Result<Void, DebugActionError> {
|
||||||
|
|
@ -326,47 +254,6 @@ enum DebugActions {
|
||||||
return .failure(.message(detail))
|
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
|
@MainActor
|
||||||
static func openSessionStoreInCode() {
|
static func openSessionStoreInCode() {
|
||||||
let path = SessionLoader.defaultStorePath
|
let path = SessionLoader.defaultStorePath
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ struct DebugSettings: View {
|
||||||
@State private var portCheckInFlight = false
|
@State private var portCheckInFlight = false
|
||||||
@State private var portReports: [DebugActions.PortReport] = []
|
@State private var portReports: [DebugActions.PortReport] = []
|
||||||
@State private var portKillStatus: String?
|
@State private var portKillStatus: String?
|
||||||
|
@State private var pendingKill: DebugActions.PortListener?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
|
|
@ -105,19 +106,28 @@ struct DebugSettings: View {
|
||||||
Text(report.summary)
|
Text(report.summary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if !report.offenders.isEmpty {
|
ForEach(report.listeners) { listener in
|
||||||
ForEach(report.offenders) { offender in
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("\(offender.command) (\(offender.pid))")
|
Text("\(listener.command) (\(listener.pid))")
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(listener.expected ? .secondary : Color.red)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Kill") {
|
Button("Kill") {
|
||||||
Task { await self.kill(offender.pid) }
|
self.requestKill(listener)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.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)
|
.padding(8)
|
||||||
|
|
@ -264,6 +274,16 @@ struct DebugSettings: View {
|
||||||
await self.reloadModels()
|
await self.reloadModels()
|
||||||
self.loadSessionStorePath()
|
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
|
@MainActor
|
||||||
|
|
@ -276,8 +296,17 @@ struct DebugSettings: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func kill(_ pid: Int) async {
|
private func requestKill(_ listener: DebugActions.PortListener) {
|
||||||
let result = await DebugActions.killProcess(pid)
|
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 {
|
switch result {
|
||||||
case .success:
|
case .success:
|
||||||
self.portKillStatus = "Sent kill to \(pid)."
|
self.portKillStatus = "Sent kill to \(pid)."
|
||||||
|
|
|
||||||
|
|
@ -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? {
|
func describe(port: Int) async -> Descriptor? {
|
||||||
guard let listener = await self.listeners(on: port).first else { return nil }
|
guard let listener = await self.listeners(on: port).first else { return nil }
|
||||||
let path = Self.executablePath(for: listener.pid)
|
let path = Self.executablePath(for: listener.pid)
|
||||||
return Descriptor(pid: listener.pid, command: listener.command, executablePath: path)
|
return Descriptor(pid: listener.pid, command: listener.command, executablePath: path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Internals
|
// MARK: - Internals
|
||||||
|
|
||||||
private struct Listener {
|
private struct Listener {
|
||||||
let pid: Int32
|
let pid: Int32
|
||||||
let command: String
|
let command: String
|
||||||
|
let fullCommand: String
|
||||||
let user: 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] {
|
private func listeners(on port: Int) async -> [Listener] {
|
||||||
let res = await ShellExecutor.run(
|
let res = await ShellExecutor.run(
|
||||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||||
|
|
@ -101,7 +192,8 @@ actor PortGuardian {
|
||||||
|
|
||||||
func flush() {
|
func flush() {
|
||||||
if let pid = currentPid, let cmd = currentCmd {
|
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
|
currentPid = nil
|
||||||
currentCmd = nil
|
currentCmd = nil
|
||||||
|
|
@ -127,6 +219,25 @@ actor PortGuardian {
|
||||||
return listeners
|
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? {
|
private static func executablePath(for pid: Int32) -> String? {
|
||||||
#if canImport(Darwin)
|
#if canImport(Darwin)
|
||||||
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
|
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
|
||||||
|
|
@ -137,7 +248,6 @@ actor PortGuardian {
|
||||||
return nil
|
return nil
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func kill(_ pid: Int32) async -> Bool {
|
private func kill(_ pid: Int32) async -> Bool {
|
||||||
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||||
if term.ok { return true }
|
if term.ok { return true }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue