fix(macos): reduce node pairing polling
This commit is contained in:
@@ -5,6 +5,15 @@ import Foundation
|
|||||||
import OSLog
|
import OSLog
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
|
struct NodePairingReconcilePolicy {
|
||||||
|
static let activeIntervalMs: UInt64 = 15_000
|
||||||
|
static let resyncDelayMs: UInt64 = 250
|
||||||
|
|
||||||
|
static func shouldPoll(pendingCount: Int, isPresenting: Bool) -> Bool {
|
||||||
|
pendingCount > 0 || isPresenting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NodePairingApprovalPrompter {
|
final class NodePairingApprovalPrompter {
|
||||||
static let shared = NodePairingApprovalPrompter()
|
static let shared = NodePairingApprovalPrompter()
|
||||||
@@ -12,6 +21,8 @@ final class NodePairingApprovalPrompter {
|
|||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
|
||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
private var reconcileTask: Task<Void, Never>?
|
private var reconcileTask: Task<Void, Never>?
|
||||||
|
private var reconcileOnceTask: Task<Void, Never>?
|
||||||
|
private var reconcileInFlight = false
|
||||||
private var isStopping = false
|
private var isStopping = false
|
||||||
private var isPresenting = false
|
private var isPresenting = false
|
||||||
private var queue: [PendingRequest] = []
|
private var queue: [PendingRequest] = []
|
||||||
@@ -54,6 +65,13 @@ final class NodePairingApprovalPrompter {
|
|||||||
var id: String { self.requestId }
|
var id: String { self.requestId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PairingResolvedEvent: Codable {
|
||||||
|
let requestId: String
|
||||||
|
let nodeId: String
|
||||||
|
let decision: String
|
||||||
|
let ts: Double
|
||||||
|
}
|
||||||
|
|
||||||
private enum PairingResolution: String {
|
private enum PairingResolution: String {
|
||||||
case approved
|
case approved
|
||||||
case rejected
|
case rejected
|
||||||
@@ -63,9 +81,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
guard self.task == nil else { return }
|
guard self.task == nil else { return }
|
||||||
self.isStopping = false
|
self.isStopping = false
|
||||||
self.reconcileTask?.cancel()
|
self.reconcileTask?.cancel()
|
||||||
self.reconcileTask = Task { [weak self] in
|
self.reconcileTask = nil
|
||||||
await self?.reconcileLoop()
|
|
||||||
}
|
|
||||||
self.task = Task { [weak self] in
|
self.task = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
_ = try? await GatewayConnection.shared.refresh()
|
_ = try? await GatewayConnection.shared.refresh()
|
||||||
@@ -85,6 +101,8 @@ final class NodePairingApprovalPrompter {
|
|||||||
self.task = nil
|
self.task = nil
|
||||||
self.reconcileTask?.cancel()
|
self.reconcileTask?.cancel()
|
||||||
self.reconcileTask = nil
|
self.reconcileTask = nil
|
||||||
|
self.reconcileOnceTask?.cancel()
|
||||||
|
self.reconcileOnceTask = nil
|
||||||
self.queue.removeAll(keepingCapacity: false)
|
self.queue.removeAll(keepingCapacity: false)
|
||||||
self.isPresenting = false
|
self.isPresenting = false
|
||||||
self.activeRequestId = nil
|
self.activeRequestId = nil
|
||||||
@@ -108,16 +126,11 @@ final class NodePairingApprovalPrompter {
|
|||||||
timeoutMs: 6000)
|
timeoutMs: 6000)
|
||||||
guard !data.isEmpty else { return }
|
guard !data.isEmpty else { return }
|
||||||
let list = try JSONDecoder().decode(PairingList.self, from: data)
|
let list = try JSONDecoder().decode(PairingList.self, from: data)
|
||||||
let pending = list.pending.sorted { $0.ts < $1.ts }
|
let pendingCount = list.pending.count
|
||||||
guard !pending.isEmpty else { return }
|
guard pendingCount > 0 else { return }
|
||||||
await MainActor.run { [weak self] in
|
self.logger.info(
|
||||||
guard let self else { return }
|
"loaded \(pendingCount, privacy: .public) pending node pairing request(s) on startup")
|
||||||
self.logger.info(
|
await self.apply(list: list)
|
||||||
"loaded \(pending.count, privacy: .public) pending node pairing request(s) on startup")
|
|
||||||
for req in pending {
|
|
||||||
self.enqueue(req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
if attempt == 8 {
|
if attempt == 8 {
|
||||||
@@ -135,17 +148,17 @@ final class NodePairingApprovalPrompter {
|
|||||||
private func reconcileLoop() async {
|
private func reconcileLoop() async {
|
||||||
// Reconcile requests periodically so multiple running apps stay in sync
|
// Reconcile requests periodically so multiple running apps stay in sync
|
||||||
// (e.g. close dialogs + notify if another machine approves/rejects via app or CLI).
|
// (e.g. close dialogs + notify if another machine approves/rejects via app or CLI).
|
||||||
let intervalMs: UInt64 = 800
|
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
if self.isStopping { return }
|
if self.isStopping { break }
|
||||||
do {
|
if !self.shouldPoll {
|
||||||
let list = try await self.fetchPairingList(timeoutMs: 2500)
|
self.reconcileTask = nil
|
||||||
await self.apply(list: list)
|
return
|
||||||
} catch {
|
|
||||||
// best effort: ignore transient connectivity failures
|
|
||||||
}
|
}
|
||||||
try? await Task.sleep(nanoseconds: intervalMs * 1_000_000)
|
await self.reconcileOnce(timeoutMs: 2500)
|
||||||
|
try? await Task.sleep(
|
||||||
|
nanoseconds: NodePairingReconcilePolicy.activeIntervalMs * 1_000_000)
|
||||||
}
|
}
|
||||||
|
self.reconcileTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchPairingList(timeoutMs: Double) async throws -> PairingList {
|
private func fetchPairingList(timeoutMs: Double) async throws -> PairingList {
|
||||||
@@ -193,6 +206,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
self.isPresenting = false
|
self.isPresenting = false
|
||||||
}
|
}
|
||||||
self.presentNextIfNeeded()
|
self.presentNextIfNeeded()
|
||||||
|
self.updateReconcileLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution {
|
private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution {
|
||||||
@@ -239,14 +253,32 @@ final class NodePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handle(push: GatewayPush) {
|
private func handle(push: GatewayPush) {
|
||||||
guard case let .event(evt) = push else { return }
|
switch push {
|
||||||
guard evt.event == "node.pair.requested" else { return }
|
case let .event(evt) where evt.event == "node.pair.requested":
|
||||||
guard let payload = evt.payload else { return }
|
guard let payload = evt.payload else { return }
|
||||||
do {
|
do {
|
||||||
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
|
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
|
||||||
self.enqueue(req)
|
self.enqueue(req)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)")
|
self.logger
|
||||||
|
.error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
case let .event(evt) where evt.event == "node.pair.resolved":
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
do {
|
||||||
|
let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self)
|
||||||
|
self.handleResolved(resolved)
|
||||||
|
} catch {
|
||||||
|
self.logger
|
||||||
|
.error(
|
||||||
|
"failed to decode pairing resolution: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
case .snapshot:
|
||||||
|
self.scheduleReconcileOnce(delayMs: 0)
|
||||||
|
case .seqGap:
|
||||||
|
self.scheduleReconcileOnce()
|
||||||
|
default:
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +286,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
if self.queue.contains(req) { return }
|
if self.queue.contains(req) { return }
|
||||||
self.queue.append(req)
|
self.queue.append(req)
|
||||||
self.presentNextIfNeeded()
|
self.presentNextIfNeeded()
|
||||||
|
self.updateReconcileLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentNextIfNeeded() {
|
private func presentNextIfNeeded() {
|
||||||
@@ -324,6 +357,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
self.isPresenting = false
|
self.isPresenting = false
|
||||||
self.presentNextIfNeeded()
|
self.presentNextIfNeeded()
|
||||||
|
self.updateReconcileLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never approve/reject while shutting down (alerts can get dismissed during app termination).
|
// Never approve/reject while shutting down (alerts can get dismissed during app termination).
|
||||||
@@ -462,6 +496,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
self.isPresenting = false
|
self.isPresenting = false
|
||||||
self.presentNextIfNeeded()
|
self.presentNextIfNeeded()
|
||||||
|
self.updateReconcileLoop()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,4 +569,74 @@ final class NodePairingApprovalPrompter {
|
|||||||
return process.terminationStatus == 0
|
return process.terminationStatus == 0
|
||||||
}.value
|
}.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var shouldPoll: Bool {
|
||||||
|
NodePairingReconcilePolicy.shouldPoll(
|
||||||
|
pendingCount: self.queue.count,
|
||||||
|
isPresenting: self.isPresenting)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateReconcileLoop() {
|
||||||
|
guard !self.isStopping else { return }
|
||||||
|
if self.shouldPoll {
|
||||||
|
if self.reconcileTask == nil {
|
||||||
|
self.reconcileTask = Task { [weak self] in
|
||||||
|
await self?.reconcileLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.reconcileTask?.cancel()
|
||||||
|
self.reconcileTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reconcileOnce(timeoutMs: Double) async {
|
||||||
|
if self.isStopping { return }
|
||||||
|
if self.reconcileInFlight { return }
|
||||||
|
self.reconcileInFlight = true
|
||||||
|
defer { self.reconcileInFlight = false }
|
||||||
|
do {
|
||||||
|
let list = try await self.fetchPairingList(timeoutMs: timeoutMs)
|
||||||
|
await self.apply(list: list)
|
||||||
|
} catch {
|
||||||
|
// best effort: ignore transient connectivity failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleReconcileOnce(delayMs: UInt64 = NodePairingReconcilePolicy.resyncDelayMs) {
|
||||||
|
self.reconcileOnceTask?.cancel()
|
||||||
|
self.reconcileOnceTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if delayMs > 0 {
|
||||||
|
try? await Task.sleep(nanoseconds: delayMs * 1_000_000)
|
||||||
|
}
|
||||||
|
await self.reconcileOnce(timeoutMs: 2500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleResolved(_ resolved: PairingResolvedEvent) {
|
||||||
|
let resolution: PairingResolution =
|
||||||
|
resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected
|
||||||
|
|
||||||
|
if self.activeRequestId == resolved.requestId, self.activeAlert != nil {
|
||||||
|
self.remoteResolutionsByRequestId[resolved.requestId] = resolution
|
||||||
|
self.logger.info(
|
||||||
|
"pairing request resolved elsewhere; closing dialog requestId=\(resolved.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)")
|
||||||
|
self.endActiveAlert()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let request = self.queue.first(where: { $0.requestId == resolved.requestId }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.queue.removeAll { $0.requestId == resolved.requestId }
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.notify(resolution: resolution, request: request, via: "remote")
|
||||||
|
}
|
||||||
|
if self.queue.isEmpty {
|
||||||
|
self.isPresenting = false
|
||||||
|
}
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
self.updateReconcileLoop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct NodePairingReconcilePolicyTests {
|
||||||
|
@Test func policyPollsOnlyWhenActive() {
|
||||||
|
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: false) == false)
|
||||||
|
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 1, isPresenting: false))
|
||||||
|
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func policyUsesSlowSafetyInterval() {
|
||||||
|
#expect(NodePairingReconcilePolicy.activeIntervalMs >= 10_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,8 @@ Implementation pointers:
|
|||||||
- Gateway handlers + events: `src/gateway/server.ts`
|
- Gateway handlers + events: `src/gateway/server.ts`
|
||||||
- Pairing store: `src/infra/node-pairing.ts` (under `~/.clawdis/nodes/`)
|
- Pairing store: `src/infra/node-pairing.ts` (under `~/.clawdis/nodes/`)
|
||||||
- Optional macOS UI prompt (frontend only): `apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift`
|
- Optional macOS UI prompt (frontend only): `apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift`
|
||||||
|
- Push-first: listens to `node.pair.requested`/`node.pair.resolved`, does a `node.pair.list` on startup/reconnect,
|
||||||
|
and only runs a slow safety poll while a request is pending/visible.
|
||||||
|
|
||||||
## Storage (private, local)
|
## Storage (private, local)
|
||||||
Gateway stores the authoritative state under `~/.clawdis/`:
|
Gateway stores the authoritative state under `~/.clawdis/`:
|
||||||
|
|||||||
Reference in New Issue
Block a user