From f6ade5dc84ac2554caa9eba407d97a47fad63330 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Dec 2025 00:49:33 +0100 Subject: [PATCH] mac: add port diagnostics for gateway --- .../Sources/Clawdis/ControlChannel.swift | 13 +- apps/macos/Sources/Clawdis/DebugActions.swift | 132 ++++++++++++++++++ .../macos/Sources/Clawdis/DebugSettings.swift | 74 ++++++++++ 3 files changed, 218 insertions(+), 1 deletion(-) diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 123dccdaf..ae9507495 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -134,6 +134,18 @@ final class ControlChannel: ObservableObject { return desc } + // Common misfire: we connected to localhost:18789 but the port is occupied + // by some other process (e.g. a local dev gateway or a stuck SSH forward). + // The gateway handshake returns something we can't parse, which currently + // surfaces as "hello failed (unexpected response)". Give the user a pointer + // to free the port instead of a vague message. + let nsError = error as NSError + if nsError.domain == "Gateway", + nsError.localizedDescription.contains("hello failed (unexpected response)") { + let port = GatewayEnvironment.gatewayPort() + return "Gateway handshake got non-gateway data on localhost:\(port). Another process is using that port or the SSH forward failed. Stop the local gateway/port-forward on \(port) and retry Remote mode." + } + if let urlError = error as? URLError { let port = GatewayEnvironment.gatewayPort() switch urlError.code { @@ -152,7 +164,6 @@ final class ControlChannel: ObservableObject { } } - let nsError = error as NSError if nsError.domain == "Gateway", nsError.code == 5 { return "Gateway request timed out; check the gateway process on localhost:\(GatewayEnvironment.gatewayPort())." } diff --git a/apps/macos/Sources/Clawdis/DebugActions.swift b/apps/macos/Sources/Clawdis/DebugActions.swift index 6a366ce49..6dceeda5f 100644 --- a/apps/macos/Sources/Clawdis/DebugActions.swift +++ b/apps/macos/Sources/Clawdis/DebugActions.swift @@ -242,6 +242,138 @@ enum DebugActions { try encoded.write(to: url, options: [.atomic]) } + // 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 + } + } + } + + 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 + } + + static func killProcess(_ pid: Int) async -> Result { + let primary = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) + if primary.ok { return .success(()) } + let force = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2) + if force.ok { return .success(()) } + let detail = force.message ?? primary.message ?? "kill failed" + 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 9730330d1..a3ceef788 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -18,6 +18,9 @@ struct DebugSettings: View { @State private var debugSendInFlight = false @State private var debugSendStatus: String? @State private var debugSendError: String? + @State private var portCheckInFlight = false + @State private var portReports: [DebugActions.PortReport] = [] + @State private var portKillStatus: String? var body: some View { ScrollView(.vertical) { @@ -73,6 +76,56 @@ struct DebugSettings: View { .frame(height: 180) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2))) } + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text("Port diagnostics") + .font(.caption.weight(.semibold)) + if self.portCheckInFlight { ProgressView().controlSize(.small) } + Spacer() + Button("Check gateway ports") { + Task { await self.runPortCheck() } + } + .buttonStyle(.borderedProminent) + .disabled(self.portCheckInFlight) + } + if let portKillStatus { + Text(portKillStatus) + .font(.caption2) + .foregroundStyle(.secondary) + } + if self.portReports.isEmpty && !self.portCheckInFlight { + Text("Check which process owns 18788/18789 and suggest fixes.") + .font(.caption2) + .foregroundStyle(.secondary) + } else { + ForEach(self.portReports) { report in + VStack(alignment: .leading, spacing: 4) { + Text("Port \(report.port)") + .font(.footnote.weight(.semibold)) + Text(report.summary) + .font(.caption) + .foregroundStyle(.secondary) + if !report.offenders.isEmpty { + ForEach(report.offenders) { offender in + HStack(spacing: 8) { + Text("\(offender.command) (\(offender.pid))") + .font(.caption.monospaced()) + .lineLimit(1) + Spacer() + Button("Kill") { + Task { await self.kill(offender.pid) } + } + .buttonStyle(.bordered) + } + } + } + } + .padding(8) + .background(Color.secondary.opacity(0.08)) + .cornerRadius(6) + } + } + } VStack(alignment: .leading, spacing: 6) { Text("Clawdis project root") .font(.caption.weight(.semibold)) @@ -213,6 +266,27 @@ struct DebugSettings: View { } } + @MainActor + private func runPortCheck() async { + self.portCheckInFlight = true + self.portKillStatus = nil + let reports = await DebugActions.checkGatewayPorts() + self.portReports = reports + self.portCheckInFlight = false + } + + @MainActor + private func kill(_ pid: Int) async { + let result = await DebugActions.killProcess(pid) + switch result { + case .success: + self.portKillStatus = "Sent kill to \(pid)." + await self.runPortCheck() + case let .failure(err): + self.portKillStatus = "Kill \(pid) failed: \(err.localizedDescription)" + } + } + private func chooseCatalogFile() { let panel = NSOpenPanel() panel.title = "Select models.generated.ts"