diff --git a/apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift b/apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift index 00f93bd85..720701f50 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift @@ -7,44 +7,78 @@ final class ConnectionModeCoordinator { private let logger = Logger(subsystem: "com.clawdbot", category: "connection") private var lastMode: AppState.ConnectionMode? + private var applyTask: Task? /// Apply the requested connection mode by starting/stopping local gateway, /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels. - func apply(mode: AppState.ConnectionMode, paused: Bool) async { - if let lastMode = self.lastMode, lastMode != mode { - GatewayProcessManager.shared.clearLastFailure() - NodesStore.shared.lastError = nil + /// This spawns a background task to avoid blocking the UI during gateway startup. + /// Cancels any in-flight mode transition to prevent concurrent state changes. + func apply(mode: AppState.ConnectionMode, paused: Bool) { + // Cancel previous mode transition if still running + applyTask?.cancel() + + applyTask = Task.detached(priority: .userInitiated) { [weak self] in + await self?.applyAsync(mode: mode, paused: paused) } - self.lastMode = mode + } + + private func applyAsync(mode: AppState.ConnectionMode, paused: Bool) async { + // Check for cancellation before starting work + guard !Task.isCancelled else { return } + + let lastMode = await MainActor.run { self.lastMode } + if let lastMode, lastMode != mode { + await MainActor.run { + GatewayProcessManager.shared.clearLastFailure() + NodesStore.shared.lastError = nil + } + } + await MainActor.run { self.lastMode = mode } + + // Check for cancellation before mode-specific logic + guard !Task.isCancelled else { return } + switch mode { case .unconfigured: _ = await NodeServiceManager.stop() - NodesStore.shared.lastError = nil + guard !Task.isCancelled else { return } + await MainActor.run { NodesStore.shared.lastError = nil } await RemoteTunnelManager.shared.stopAll() - WebChatManager.shared.resetTunnels() - GatewayProcessManager.shared.stop() + guard !Task.isCancelled else { return } + await MainActor.run { + WebChatManager.shared.resetTunnels() + GatewayProcessManager.shared.stop() + } await GatewayConnection.shared.shutdown() + guard !Task.isCancelled else { return } await ControlChannel.shared.disconnect() + guard !Task.isCancelled else { return } Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } case .local: _ = await NodeServiceManager.stop() - NodesStore.shared.lastError = nil + guard !Task.isCancelled else { return } + await MainActor.run { NodesStore.shared.lastError = nil } await RemoteTunnelManager.shared.stopAll() - WebChatManager.shared.resetTunnels() + guard !Task.isCancelled else { return } + await MainActor.run { WebChatManager.shared.resetTunnels() } let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) if shouldStart { - GatewayProcessManager.shared.setActive(true) + guard !Task.isCancelled else { return } + await MainActor.run { GatewayProcessManager.shared.setActive(true) } if GatewayAutostartPolicy.shouldEnsureLaunchAgent( mode: .local, paused: paused) { Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() } } - _ = await GatewayProcessManager.shared.waitForGatewayReady() + // Note: GatewayProcessManager.startIfNeeded() includes its own readiness polling + // via enableLaunchdGateway() (which waits up to 6 seconds for the gateway to accept + // connections), so no additional wait is needed here. } else { - GatewayProcessManager.shared.stop() + await MainActor.run { GatewayProcessManager.shared.stop() } } + guard !Task.isCancelled else { return } do { try await ControlChannel.shared.configure(mode: .local) } catch { @@ -52,19 +86,24 @@ final class ConnectionModeCoordinator { self.logger.error( "control channel local configure failed: \(error.localizedDescription, privacy: .public)") } + guard !Task.isCancelled else { return } Task.detached { await PortGuardian.shared.sweep(mode: .local) } case .remote: // Never run a local gateway in remote mode. - GatewayProcessManager.shared.stop() - WebChatManager.shared.resetTunnels() + await MainActor.run { GatewayProcessManager.shared.stop() } + guard !Task.isCancelled else { return } + await MainActor.run { WebChatManager.shared.resetTunnels() } do { - NodesStore.shared.lastError = nil + await MainActor.run { NodesStore.shared.lastError = nil } + guard !Task.isCancelled else { return } if let error = await NodeServiceManager.start() { - NodesStore.shared.lastError = "Node service start failed: \(error)" + await MainActor.run { NodesStore.shared.lastError = "Node service start failed: \(error)" } } + guard !Task.isCancelled else { return } _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + guard !Task.isCancelled else { return } let settings = CommandResolver.connectionSettings() try await ControlChannel.shared.configure(mode: .remote( target: settings.target, @@ -72,6 +111,7 @@ final class ConnectionModeCoordinator { } catch { self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") } + guard !Task.isCancelled else { return } Task.detached { await PortGuardian.shared.sweep(mode: .remote) } } diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index 7facc6d61..c8e72191c 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -138,10 +138,18 @@ actor GatewayConnection { let mode = await MainActor.run { AppStateStore.shared.connectionMode } switch mode { case .local: + // Trigger gateway start. This suspends (non-blocking) until the MainActor schedules it. await MainActor.run { GatewayProcessManager.shared.setActive(true) } var lastError: Error = error - for delayMs in [150, 400, 900] { + // Short retry delays (150ms total) for Canvas responsiveness. Gateway startup happens + // asynchronously in the background, so these fast retries typically succeed on the + // 2nd or 3rd attempt once the gateway accepts connections. + // + // Tradeoff: Cold-start scenarios (first launch, after sleep) may take 2-3 seconds, + // but Canvas operations prioritize fast failure feedback over waiting. If retries + // fail, the user sees an error immediately rather than a frozen UI. + for delayMs in [50, 100] { try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) do { return try await client.request(method: method, params: params, timeoutMs: timeoutMs) @@ -155,7 +163,9 @@ actor GatewayConnection { let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url) { await self.configure(url: fallback.url, token: fallback.token, password: fallback.password) - for delayMs in [150, 400, 900] { + // Fast retries for Tailnet fallback (150ms total). Tailnet connections should be + // quick since the gateway is already running; short delays provide fast feedback. + for delayMs in [50, 100] { try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) do { guard let client = self.client else { @@ -184,7 +194,9 @@ actor GatewayConnection { lastError = error } - for delayMs in [150, 400, 900] { + // Fast retries for remote tunnel recovery (150ms total). SSH tunnel setup is relatively + // quick; short delays allow fast recovery or fast failure feedback. + for delayMs in [50, 100] { try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) do { let cfg = try await self.configProvider() diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift index 60964fa39..47232614d 100644 --- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift @@ -6,17 +6,43 @@ import Observation final class GatewayProcessManager { static let shared = GatewayProcessManager() + /// Represents the lifecycle state of the local gateway process. + /// + /// Valid state transitions: + /// - `.stopped` → `.starting(phase)` → (`.running` | `.attachedExisting` | `.failed`) + /// - `.failed` → `.starting(phase)` (user retry) + /// - any state → `.stopped` (manual stop) enum Status: Equatable { + /// Gateway is not running and not attempting to start. case stopped - case starting + + /// Gateway startup is in progress. + /// - Parameter phase: Optional startup phase for user feedback: + /// - `"checking"`: Initial state, determining what to do + /// - `"attaching"`: Attempting to connect to existing gateway process + /// - `"launching"`: Launching new gateway via launchd + /// - `"waiting for ready"`: Polling for gateway to accept connections + case starting(phase: String?) + + /// Gateway was freshly launched by this app and is now accepting connections. + /// - Parameter details: Optional process details (e.g., "pid 12345") case running(details: String?) + + /// Gateway was already running (launched externally or by previous app instance) + /// and this app successfully attached to it. + /// - Parameter details: Optional process and health details case attachedExisting(details: String?) + + /// Gateway failed to start or attach. + /// - Parameter message: Human-readable failure reason shown to user case failed(String) var label: String { switch self { case .stopped: return "Stopped" - case .starting: return "Starting…" + case let .starting(phase): + if let phase, !phase.isEmpty { return "Starting (\(phase))…" } + return "Starting…" case let .running(details): if let details, !details.isEmpty { return "Running (\(details))" } return "Running" @@ -110,15 +136,17 @@ final class GatewayProcessManager { case .stopped, .failed: break } - self.status = .starting + self.status = .starting(phase: "checking") self.logger.debug("gateway start requested") // First try to latch onto an already-running gateway to avoid spawning a duplicate. Task { [weak self] in guard let self else { return } + await MainActor.run { self.status = .starting(phase: "attaching") } if await self.attachExistingGatewayIfAvailable() { return } + await MainActor.run { self.status = .starting(phase: "launching") } await self.enableLaunchdGateway() } } @@ -205,10 +233,12 @@ final class GatewayProcessManager { let data = try await attemptAttach() let snap = decodeHealthSnapshot(from: data) let details = self.describe(details: instanceText, port: port, snap: snap) - self.existingGatewayDetails = details - self.clearLastFailure() - self.status = .attachedExisting(details: details) - self.appendLog("[gateway] using existing instance: \(details)\n") + await MainActor.run { + self.existingGatewayDetails = details + self.clearLastFailure() + self.status = .attachedExisting(details: details) + self.appendLog("[gateway] using existing instance: \(details)\n") + } self.logger.info("gateway using existing instance details=\(details)") self.refreshControlChannelIfNeeded(reason: "attach existing") self.refreshLog() @@ -221,21 +251,23 @@ final class GatewayProcessManager { if hasListener { let reason = self.describeAttachFailure(error, port: port, instance: instance) - self.existingGatewayDetails = instanceText - self.status = .failed(reason) - self.lastFailureReason = reason - self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n") + await MainActor.run { + self.existingGatewayDetails = instanceText + self.status = .failed(reason) + self.lastFailureReason = reason + self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n") + } self.logger.warning("gateway attach failed reason=\(reason)") return true } // No reachable gateway (and no listener) — fall through to spawn. - self.existingGatewayDetails = nil + await MainActor.run { self.existingGatewayDetails = nil } return false } } - self.existingGatewayDetails = nil + await MainActor.run { self.existingGatewayDetails = nil } return false } @@ -298,7 +330,7 @@ final class GatewayProcessManager { } private func enableLaunchdGateway() async { - self.existingGatewayDetails = nil + await MainActor.run { self.existingGatewayDetails = nil } let resolution = await Task.detached(priority: .utility) { GatewayEnvironment.resolveGatewayCommand() }.value @@ -313,26 +345,31 @@ final class GatewayProcessManager { if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { let message = "Launchd disabled; start the Gateway manually or disable attach-only." - self.status = .failed(message) - self.lastFailureReason = "launchd disabled" - self.appendLog("[gateway] launchd disabled; skipping auto-start\n") + await MainActor.run { + self.status = .failed(message) + self.lastFailureReason = "launchd disabled" + self.appendLog("[gateway] launchd disabled; skipping auto-start\n") + } self.logger.info("gateway launchd enable skipped (disable marker set)") return } let bundlePath = Bundle.main.bundleURL.path let port = GatewayEnvironment.gatewayPort() - self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") + await MainActor.run { self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") } self.logger.info("gateway enabling launchd port=\(port)") let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) if let err { - self.status = .failed(err) - self.lastFailureReason = err + await MainActor.run { + self.status = .failed(err) + self.lastFailureReason = err + } self.logger.error("gateway launchd enable failed: \(err)") return } // Best-effort: wait for the gateway to accept connections. + await MainActor.run { self.status = .starting(phase: "waiting for ready") } let deadline = Date().addingTimeInterval(6) while Date() < deadline { if !self.desiredActive { return } @@ -340,8 +377,10 @@ final class GatewayProcessManager { _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) let instance = await PortGuardian.shared.describe(port: port) let details = instance.map { "pid \($0.pid)" } - self.clearLastFailure() - self.status = .running(details: details) + await MainActor.run { + self.clearLastFailure() + self.status = .running(details: details) + } self.logger.info("gateway started details=\(details ?? "ok")") self.refreshControlChannelIfNeeded(reason: "gateway started") self.refreshLog() @@ -351,8 +390,10 @@ final class GatewayProcessManager { } } - self.status = .failed("Gateway did not start in time") - self.lastFailureReason = "launchd start timeout" + await MainActor.run { + self.status = .failed("Gateway did not start in time") + self.lastFailureReason = "launchd start timeout" + } self.logger.warning("gateway start timed out") } diff --git a/apps/macos/Sources/Clawdbot/MenuBar.swift b/apps/macos/Sources/Clawdbot/MenuBar.swift index 26a467e91..7a8dafe9c 100644 --- a/apps/macos/Sources/Clawdbot/MenuBar.swift +++ b/apps/macos/Sources/Clawdbot/MenuBar.swift @@ -73,7 +73,7 @@ struct ClawdbotApp: App { self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) } .onChange(of: self.state.connectionMode) { _, mode in - Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } + ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode") } @@ -272,7 +272,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.state = AppStateStore.shared AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) if let state { - Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) } + ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) } TerminationSignalWatcher.shared.start() NodePairingApprovalPrompter.shared.start()