diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift index d9b7c5777..b6ddf6451 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift @@ -23,6 +23,9 @@ actor MacNodeBridgeSession { private var buffer = Data() private var pendingRPC: [String: CheckedContinuation] = [:] private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] + private var pingTask: Task? + private var lastPongAt: Date? + private var lastPingId: String? private(set) var state: State = .idle @@ -77,6 +80,7 @@ actor MacNodeBridgeSession { if base.type == "hello-ok" { let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) self.state = .connected(serverName: ok.serverName) + self.startPingLoop() await onConnected?(ok.serverName) } else if base.type == "error" { let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) @@ -113,6 +117,10 @@ actor MacNodeBridgeSession { let ping = try self.decoder.decode(BridgePing.self, from: nextData) try await self.send(BridgePong(type: "pong", id: ping.id)) + case "pong": + let pong = try self.decoder.decode(BridgePong.self, from: nextData) + self.notePong(pong) + case "invoke": let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData) let res = await onInvoke(req) @@ -182,6 +190,11 @@ actor MacNodeBridgeSession { } func disconnect() async { + self.pingTask?.cancel() + self.pingTask = nil + self.lastPongAt = nil + self.lastPingId = nil + self.connection?.cancel() self.connection = nil self.queue = nil @@ -280,6 +293,52 @@ actor MacNodeBridgeSession { } } + private func startPingLoop() { + self.pingTask?.cancel() + self.lastPongAt = Date() + self.pingTask = Task { [weak self] in + guard let self else { return } + await self.runPingLoop() + } + } + + private func runPingLoop() async { + let intervalSeconds = 15.0 + let timeoutSeconds = 45.0 + + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: UInt64(intervalSeconds * 1_000_000_000)) + } catch { + return + } + + guard self.connection != nil else { return } + + if let last = self.lastPongAt, + Date().timeIntervalSince(last) > timeoutSeconds + { + await self.disconnect() + return + } + + let id = UUID().uuidString + self.lastPingId = id + do { + try await self.send(BridgePing(type: "ping", id: id)) + } catch { + await self.disconnect() + return + } + } + } + + private func notePong(_ pong: BridgePong) { + if pong.id == self.lastPingId || self.lastPingId == nil { + self.lastPongAt = Date() + } + } + private static func makeStateStream( for connection: NWConnection) -> AsyncStream {