mac: add port diagnostics for gateway
This commit is contained in:
@@ -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())."
|
||||
}
|
||||
|
||||
@@ -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<Void, DebugActionError> {
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user