fix(macos): prevent duplicate menu bar icons

Fix macOS menu bar status-item storms during rapid gateway connection churn by removing stale SwiftUI-vended status items before adopting replacements and debouncing transient control-channel states.

Surface: macOS menu bar app, `MenuBarExtra` status item ownership, `ControlChannel` UI-observed connection state.

Proof:
- `git diff --check origin/main...pr/82739`
- `swift test --package-path apps/macos --filter ControlChannelStateDebouncerTests`
- PR CI: preflight, security-fast, macos-node, macos-swift, dependency-guard, changed-path scan, real behavior proof, Socket checks

Co-authored-by: Alexander Falk <al@falk.us>
This commit is contained in:
Alexander Falk
2026-05-31 11:18:37 -04:00
committed by GitHub
parent a52c4d101a
commit e18099b8c3
3 changed files with 161 additions and 12 deletions

View File

@@ -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<Void, Never>?
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<Void, Never>?
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
}

View File

@@ -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)

View File

@@ -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)
}
}