diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift index 9711b13ad151..2cfe7482e5fc 100644 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -39,6 +39,48 @@ enum ControlChannelError: Error, LocalizedError { } } +struct ControlChannelStateDebouncer { + private let interval: TimeInterval + private var lastAppliedAt: Date + + init(interval: TimeInterval = 0.5, lastAppliedAt: Date = .distantPast) { + self.interval = interval + self.lastAppliedAt = lastAppliedAt + } + + mutating func delayBeforeApplying( + currentState: ControlChannel.ConnectionState, + newState: ControlChannel.ConnectionState, + now: Date) -> TimeInterval? + { + if Self.isTerminal(currentState) || Self.isTerminal(newState) { + self.lastAppliedAt = now + return nil + } + + let elapsed = now.timeIntervalSince(self.lastAppliedAt) + guard elapsed < self.interval else { + self.lastAppliedAt = now + return nil + } + + return self.interval - max(0, elapsed) + } + + mutating func recordDeferredApply(at date: Date) { + self.lastAppliedAt = date + } + + private static func isTerminal(_ state: ControlChannel.ConnectionState) -> Bool { + switch state { + case .connected, .disconnected: + true + case .connecting, .degraded: + false + } + } +} + @MainActor @Observable final class ControlChannel { @@ -85,6 +127,46 @@ final class ControlChannel { private var recoveryTask: Task? private var lastRecoveryAt: Date? + // Coalesce rapid connecting/degraded oscillations so SwiftUI does not churn + // MenuBarExtra status items while the gateway connection is unstable. + private var pendingStateTask: Task? + private var stateDebouncer = ControlChannelStateDebouncer() + + private func setStateThrottled(_ newState: ConnectionState) { + let now = Date() + if let delay = self.stateDebouncer.delayBeforeApplying( + currentState: self.state, + newState: newState, + now: now) + { + self.pendingStateTask?.cancel() + self.pendingStateTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: Self.nanoseconds(for: delay)) + guard let self, !Task.isCancelled else { return } + self.pendingStateTask = nil + self.stateDebouncer.recordDeferredApply(at: Date()) + self.applyState(newState) + } + return + } + + self.cancelPendingStateTask() + self.applyState(newState) + } + + private func cancelPendingStateTask() { + self.pendingStateTask?.cancel() + self.pendingStateTask = nil + } + + private func applyState(_ newState: ConnectionState) { + self.state = newState + } + + private static func nanoseconds(for interval: TimeInterval) -> UInt64 { + UInt64(max(0, interval) * 1_000_000_000) + } + private init() { self.startEventStream() } @@ -105,11 +187,11 @@ final class ControlChannel { self.logger.info( "control channel configure mode=remote " + "target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") - self.state = .connecting + self.setStateThrottled(.connecting) _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() await self.refreshEndpoint(reason: "configure") } catch { - self.state = .degraded(error.localizedDescription) + self.setStateThrottled(.degraded(error.localizedDescription)) throw error } } @@ -117,20 +199,20 @@ final class ControlChannel { func refreshEndpoint(reason: String) async { self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)") - self.state = .connecting + self.setStateThrottled(.connecting) do { try await self.establishGatewayConnection() - self.state = .connected + self.setStateThrottled(.connected) PresenceReporter.shared.sendImmediate(reason: "connect") } catch { let message = self.friendlyGatewayMessage(error) - self.state = .degraded(message) + self.setStateThrottled(.degraded(message)) } } func disconnect() async { await GatewayConnection.shared.shutdown() - self.state = .disconnected + self.setStateThrottled(.disconnected) self.lastPingMs = nil self.authSourceLabel = nil } @@ -146,11 +228,11 @@ final class ControlChannel { let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs) let ms = Date().timeIntervalSince(start) * 1000 self.lastPingMs = ms - self.state = .connected + self.setStateThrottled(.connected) return payload } catch { let message = self.friendlyGatewayMessage(error) - self.state = .degraded(message) + self.setStateThrottled(.degraded(message)) throw ControlChannelError.badResponse(message) } } @@ -173,11 +255,11 @@ final class ControlChannel { method: method, params: rawParams, timeoutMs: timeoutMs) - self.state = .connected + self.setStateThrottled(.connected) return data } catch { let message = self.friendlyGatewayMessage(error) - self.state = .degraded(message) + self.setStateThrottled(.degraded(message)) throw ControlChannelError.badResponse(message) } } @@ -386,9 +468,9 @@ final class ControlChannel { NotificationCenter.default.post(name: .controlHeartbeat, object: data) } case let .event(evt) where evt.event == "shutdown": - self.state = .degraded("gateway shutdown") + self.setStateThrottled(.degraded("gateway shutdown")) case .snapshot: - self.state = .connected + self.setStateThrottled(.connected) default: break } diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index f4a9ed7aaa17..82b0257ec33a 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -55,6 +55,13 @@ struct OpenClawApp: App { .background(SettingsWindowOpenRegistrar()) } .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in + // SwiftUI can vend a replacement status item during connection churn. + // Keep ownership to one item so stale menu bar icons are removed. + if let currentStatusItem = self.statusItem { + guard currentStatusItem !== item else { return } + Self.logger.warning("Replacing stale menu bar status item") + NSStatusBar.system.removeStatusItem(currentStatusItem) + } self.statusItem = item MenuSessionsInjector.shared.install(into: item) self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) diff --git a/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift b/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift new file mode 100644 index 000000000000..9c07993855dd --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct ControlChannelStateDebouncerTests { + @Test func `terminal states apply immediately`() { + let start = Date(timeIntervalSince1970: 1_000) + var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start) + + let degradedDelay = debouncer.delayBeforeApplying( + currentState: .connecting, + newState: .degraded("gateway unavailable"), + now: start.addingTimeInterval(0.1)) + #expect(degradedDelay != nil) + + let connectedDelay = debouncer.delayBeforeApplying( + currentState: .connecting, + newState: .connected, + now: start.addingTimeInterval(0.2)) + #expect(connectedDelay == nil) + + let afterTerminalDelay = debouncer.delayBeforeApplying( + currentState: .connected, + newState: .connecting, + now: start.addingTimeInterval(0.3)) + #expect(afterTerminalDelay == nil) + } + + @Test func `nonterminal states are debounced within interval`() { + let start = Date(timeIntervalSince1970: 1_000) + var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start) + + let soonDelay = debouncer.delayBeforeApplying( + currentState: .connecting, + newState: .degraded("gateway unavailable"), + now: start.addingTimeInterval(0.1)) + #expect(soonDelay != nil) + #expect(abs((soonDelay ?? 0) - 0.4) < 0.001) + + let afterWindowDelay = debouncer.delayBeforeApplying( + currentState: .connecting, + newState: .degraded("gateway unavailable"), + now: start.addingTimeInterval(0.6)) + #expect(afterWindowDelay == nil) + } + + @Test func `deferred apply resets debounce window`() { + let start = Date(timeIntervalSince1970: 1_000) + var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start) + + debouncer.recordDeferredApply(at: start.addingTimeInterval(0.5)) + + let delayAfterDeferredUpdate = debouncer.delayBeforeApplying( + currentState: .degraded("gateway unavailable"), + newState: .connecting, + now: start.addingTimeInterval(0.7)) + #expect(delayAfterDeferredUpdate != nil) + #expect(abs((delayAfterDeferredUpdate ?? 0) - 0.3) < 0.001) + } +}