























@@ -39,6 +39,48 @@ enum ControlChannelError: Error, LocalizedError {
3939}
4040}
414142+struct ControlChannelStateDebouncer {
43+private let interval: TimeInterval
44+private var lastAppliedAt: Date
45+46+init(interval: TimeInterval = 0.5, lastAppliedAt: Date = .distantPast) {
47+self.interval = interval
48+self.lastAppliedAt = lastAppliedAt
49+}
50+51+mutating func delayBeforeApplying(
52+ currentState: ControlChannel.ConnectionState,
53+ newState: ControlChannel.ConnectionState,
54+ now: Date) -> TimeInterval?
55+{
56+if Self.isTerminal(currentState) || Self.isTerminal(newState) {
57+self.lastAppliedAt = now
58+return nil
59+}
60+61+let elapsed = now.timeIntervalSince(self.lastAppliedAt)
62+guard elapsed < self.interval else {
63+self.lastAppliedAt = now
64+return nil
65+}
66+67+return self.interval - max(0, elapsed)
68+}
69+70+mutating func recordDeferredApply(at date: Date) {
71+self.lastAppliedAt = date
72+}
73+74+private static func isTerminal(_ state: ControlChannel.ConnectionState) -> Bool {
75+switch state {
76+case .connected, .disconnected:
77+true
78+case .connecting, .degraded:
79+false
80+}
81+}
82+}
83+4284@MainActor
4385@Observable
4486final class ControlChannel {
@@ -85,6 +127,46 @@ final class ControlChannel {
85127private var recoveryTask: Task<Void, Never>?
86128private var lastRecoveryAt: Date?
87129130+ // Coalesce rapid connecting/degraded oscillations so SwiftUI does not churn
131+ // MenuBarExtra status items while the gateway connection is unstable.
132+private var pendingStateTask: Task<Void, Never>?
133+private var stateDebouncer = ControlChannelStateDebouncer()
134+135+private func setStateThrottled(_ newState: ConnectionState) {
136+let now = Date()
137+if let delay = self.stateDebouncer.delayBeforeApplying(
138+ currentState: self.state,
139+ newState: newState,
140+ now: now)
141+{
142+self.pendingStateTask?.cancel()
143+self.pendingStateTask = Task { [weak self] in
144+try? await Task.sleep(nanoseconds: Self.nanoseconds(for: delay))
145+guard let self, !Task.isCancelled else { return }
146+self.pendingStateTask = nil
147+self.stateDebouncer.recordDeferredApply(at: Date())
148+self.applyState(newState)
149+}
150+return
151+}
152+153+self.cancelPendingStateTask()
154+self.applyState(newState)
155+}
156+157+private func cancelPendingStateTask() {
158+self.pendingStateTask?.cancel()
159+self.pendingStateTask = nil
160+}
161+162+private func applyState(_ newState: ConnectionState) {
163+self.state = newState
164+}
165+166+private static func nanoseconds(for interval: TimeInterval) -> UInt64 {
167+UInt64(max(0, interval) * 1_000_000_000)
168+}
169+88170private init() {
89171self.startEventStream()
90172}
@@ -105,32 +187,32 @@ final class ControlChannel {
105187self.logger.info(
106188"control channel configure mode=remote " +
107189"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
108-self.state = .connecting
190+self.setStateThrottled(.connecting)
109191 _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
110192await self.refreshEndpoint(reason: "configure")
111193} catch {
112-self.state = .degraded(error.localizedDescription)
194+self.setStateThrottled(.degraded(error.localizedDescription))
113195throw error
114196}
115197}
116198}
117199118200func refreshEndpoint(reason: String) async {
119201self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
120-self.state = .connecting
202+self.setStateThrottled(.connecting)
121203do {
122204try await self.establishGatewayConnection()
123-self.state = .connected
205+self.setStateThrottled(.connected)
124206PresenceReporter.shared.sendImmediate(reason: "connect")
125207} catch {
126208let message = self.friendlyGatewayMessage(error)
127-self.state = .degraded(message)
209+self.setStateThrottled(.degraded(message))
128210}
129211}
130212131213func disconnect() async {
132214await GatewayConnection.shared.shutdown()
133-self.state = .disconnected
215+self.setStateThrottled(.disconnected)
134216self.lastPingMs = nil
135217self.authSourceLabel = nil
136218}
@@ -146,11 +228,11 @@ final class ControlChannel {
146228let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs)
147229let ms = Date().timeIntervalSince(start) * 1000
148230self.lastPingMs = ms
149-self.state = .connected
231+self.setStateThrottled(.connected)
150232return payload
151233} catch {
152234let message = self.friendlyGatewayMessage(error)
153-self.state = .degraded(message)
235+self.setStateThrottled(.degraded(message))
154236throw ControlChannelError.badResponse(message)
155237}
156238}
@@ -173,11 +255,11 @@ final class ControlChannel {
173255 method: method,
174256 params: rawParams,
175257 timeoutMs: timeoutMs)
176-self.state = .connected
258+self.setStateThrottled(.connected)
177259return data
178260} catch {
179261let message = self.friendlyGatewayMessage(error)
180-self.state = .degraded(message)
262+self.setStateThrottled(.degraded(message))
181263throw ControlChannelError.badResponse(message)
182264}
183265}
@@ -386,9 +468,9 @@ final class ControlChannel {
386468NotificationCenter.default.post(name: .controlHeartbeat, object: data)
387469}
388470case let .event(evt) where evt.event == "shutdown":
389-self.state = .degraded("gateway shutdown")
471+self.setStateThrottled(.degraded("gateway shutdown"))
390472case .snapshot:
391-self.state = .connected
473+self.setStateThrottled(.connected)
392474default:
393475break
394476}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。