refactor: unify gateway connectivity state
This commit is contained in:
@@ -87,15 +87,7 @@ final class ControlChannel {
|
|||||||
|
|
||||||
func configure() async {
|
func configure() async {
|
||||||
self.logger.info("control channel configure mode=local")
|
self.logger.info("control channel configure mode=local")
|
||||||
self.state = .connecting
|
await self.refreshEndpoint(reason: "configure")
|
||||||
do {
|
|
||||||
try await GatewayConnection.shared.refresh()
|
|
||||||
self.state = .connected
|
|
||||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
|
||||||
} catch {
|
|
||||||
let message = self.friendlyGatewayMessage(error)
|
|
||||||
self.state = .degraded(message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(mode: Mode = .local) async throws {
|
func configure(mode: Mode = .local) async throws {
|
||||||
@@ -111,7 +103,7 @@ final class ControlChannel {
|
|||||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||||
self.state = .connecting
|
self.state = .connecting
|
||||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||||
await self.configure()
|
await self.refreshEndpoint(reason: "configure")
|
||||||
} catch {
|
} catch {
|
||||||
self.state = .degraded(error.localizedDescription)
|
self.state = .degraded(error.localizedDescription)
|
||||||
throw error
|
throw error
|
||||||
@@ -119,6 +111,19 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshEndpoint(reason: String) async {
|
||||||
|
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
|
||||||
|
self.state = .connecting
|
||||||
|
do {
|
||||||
|
try await self.establishGatewayConnection()
|
||||||
|
self.state = .connected
|
||||||
|
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||||
|
} catch {
|
||||||
|
let message = self.friendlyGatewayMessage(error)
|
||||||
|
self.state = .degraded(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func disconnect() async {
|
func disconnect() async {
|
||||||
await GatewayConnection.shared.shutdown()
|
await GatewayConnection.shared.shutdown()
|
||||||
self.state = .disconnected
|
self.state = .disconnected
|
||||||
@@ -275,18 +280,28 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||||
try await GatewayConnection.shared.refresh()
|
if case .connected = self.state {
|
||||||
self.logger.info("control channel recovery finished")
|
self.logger.info("control channel recovery finished")
|
||||||
} catch {
|
} else if case let .degraded(message) = self.state {
|
||||||
self.logger.error(
|
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recoveryTask = nil
|
self.recoveryTask = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
|
||||||
|
try await GatewayConnection.shared.refresh()
|
||||||
|
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||||
|
if ok == false {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
var merged = params
|
var merged = params
|
||||||
merged["text"] = AnyHashable(text)
|
merged["text"] = AnyHashable(text)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class GatewayConnectivityCoordinator {
|
||||||
|
static let shared = GatewayConnectivityCoordinator()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
|
||||||
|
private var endpointTask: Task<Void, Never>?
|
||||||
|
private var lastResolvedURL: URL?
|
||||||
|
|
||||||
|
private(set) var endpointState: GatewayEndpointState?
|
||||||
|
private(set) var resolvedURL: URL?
|
||||||
|
private(set) var resolvedMode: AppState.ConnectionMode?
|
||||||
|
private(set) var resolvedHostLabel: String?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.endpointTask == nil else { return }
|
||||||
|
self.endpointTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let stream = await GatewayEndpointStore.shared.subscribe()
|
||||||
|
for await state in stream {
|
||||||
|
await MainActor.run { self.handleEndpointState(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var localEndpointHostLabel: String? {
|
||||||
|
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
|
||||||
|
return Self.hostLabel(for: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEndpointState(_ state: GatewayEndpointState) {
|
||||||
|
self.endpointState = state
|
||||||
|
switch state {
|
||||||
|
case let .ready(mode, url, _, _):
|
||||||
|
self.resolvedMode = mode
|
||||||
|
self.resolvedURL = url
|
||||||
|
self.resolvedHostLabel = Self.hostLabel(for: url)
|
||||||
|
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
|
||||||
|
if urlChanged {
|
||||||
|
self.lastResolvedURL = url
|
||||||
|
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
|
||||||
|
}
|
||||||
|
case let .connecting(mode, _):
|
||||||
|
self.resolvedMode = mode
|
||||||
|
case let .unavailable(mode, _):
|
||||||
|
self.resolvedMode = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hostLabel(for url: URL) -> String {
|
||||||
|
let host = url.host ?? url.absoluteString
|
||||||
|
if let port = url.port { return "\(host):\(port)" }
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ actor GatewayEndpointStore {
|
|||||||
env: ProcessInfo.processInfo.environment)
|
env: ProcessInfo.processInfo.environment)
|
||||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
?? TailscaleService.fallbackTailnetIPv4()
|
||||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||||
bindMode: bind,
|
bindMode: bind,
|
||||||
customBindHost: customBindHost,
|
customBindHost: customBindHost,
|
||||||
@@ -487,6 +488,7 @@ actor GatewayEndpointStore {
|
|||||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||||
|
|
||||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
?? TailscaleService.fallbackTailnetIPv4()
|
||||||
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||||
|
|
||||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
|
|||||||
@@ -235,8 +235,8 @@ final class HealthStore {
|
|||||||
let lower = error.lowercased()
|
let lower = error.lowercased()
|
||||||
if lower.contains("connection refused") {
|
if lower.contains("connection refused") {
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||||
"restart Clawdbot to bring it back."
|
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
||||||
}
|
}
|
||||||
if lower.contains("timeout") {
|
if lower.contains("timeout") {
|
||||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct ClawdbotApp: App {
|
|||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
private let controlChannel = ControlChannel.shared
|
private let controlChannel = ControlChannel.shared
|
||||||
private let activityStore = WorkActivityStore.shared
|
private let activityStore = WorkActivityStore.shared
|
||||||
|
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||||
@State private var statusItem: NSStatusItem?
|
@State private var statusItem: NSStatusItem?
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isPanelVisible = false
|
@State private var isPanelVisible = false
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
case .local:
|
case .local:
|
||||||
platform = "local"
|
platform = "local"
|
||||||
host = "127.0.0.1:\(port)"
|
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
platform = nil
|
platform = nil
|
||||||
host = nil
|
host = nil
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ final class TailscaleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkTailscaleStatus() async {
|
func checkTailscaleStatus() async {
|
||||||
|
let previousIP = self.tailscaleIP
|
||||||
self.isInstalled = self.checkAppInstallation()
|
self.isInstalled = self.checkAppInstallation()
|
||||||
if !self.isInstalled {
|
if !self.isInstalled {
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
@@ -147,6 +148,10 @@ final class TailscaleService {
|
|||||||
self.statusError = nil
|
self.statusError = nil
|
||||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if previousIP != self.tailscaleIP {
|
||||||
|
await GatewayEndpointStore.shared.refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func openTailscaleApp() {
|
func openTailscaleApp() {
|
||||||
@@ -214,4 +219,8 @@ final class TailscaleService {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||||
|
Self.detectTailnetIPv4()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user