From 66bc003126192dcf2eb5416bed053a9c21a0e4c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 22:25:05 +0100 Subject: [PATCH] fix: harden mac bridge disconnect handling (#676) (thanks @ngutman) --- CHANGELOG.md | 3 +- .../NodeMode/MacNodeBridgeSession.swift | 29 +++++++++++++++---- .../LowCoverageHelperTests.swift | 19 ++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c074ed7..2525af715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,8 @@ - Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680) — thanks @steipete. ### Fixes -- Block Streaming: enable for all providers, not just Telegram. (#684) — thanks @rubyrunsstuff. +- macOS: stabilize bridge tunnels, guard invoke senders on disconnect, and drain stdout/stderr to avoid deadlocks. (#676) — thanks @ngutman. - Agents/System: clarify sandboxed runtime in system prompt and surface elevated availability when sandboxed. -- Agents/System: add reasoning visibility hint + /reasoning and /status guidance in system prompt. - Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj. - WhatsApp: fix group reactions by preserving message IDs and sender JIDs in history; normalize participant phone numbers to JIDs in outbound reactions. (#640) — thanks @mcinteerj. - WhatsApp: expose group participant IDs to the model so reactions can target the right sender. diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift index 7b7dfeff3..a23caa507 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift @@ -27,6 +27,7 @@ actor MacNodeBridgeSession { private var buffer = Data() private var pendingRPC: [String: CheckedContinuation] = [:] private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] + private var invokeTasks: [UUID: Task] = [:] private var pingTask: Task? private var lastPongAt: ContinuousClock.Instant? @@ -142,15 +143,13 @@ actor MacNodeBridgeSession { case "invoke": let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData) - Task.detached { [weak self] in + let taskID = UUID() + let task = Task { [weak self] in let res = await onInvoke(req) guard let self else { return } - do { - try await self.send(res) - } catch { - await self.logInvokeSendFailure(error) - } + await self.sendInvokeResponse(res, taskID: taskID) } + self.invokeTasks[taskID] = task default: continue @@ -226,6 +225,7 @@ actor MacNodeBridgeSession { self.pingTask = nil self.lastPongAt = nil self.disconnectHandler = nil + self.cancelInvokeTasks() self.connection?.cancel() self.connection = nil @@ -413,6 +413,23 @@ actor MacNodeBridgeSession { "node bridge invoke response send failed: \(error.localizedDescription, privacy: .public)") } + private func sendInvokeResponse(_ response: BridgeInvokeResponse, taskID: UUID) async { + defer { self.invokeTasks[taskID] = nil } + if Task.isCancelled { return } + do { + try await self.send(response) + } catch { + await self.logInvokeSendFailure(error) + } + } + + private func cancelInvokeTasks() { + for task in self.invokeTasks.values { + task.cancel() + } + self.invokeTasks.removeAll() + } + private static func makeStateStream( for connection: NWConnection) -> AsyncStream { diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift index 49547bd98..11bd5a885 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift @@ -57,6 +57,25 @@ struct LowCoverageHelperTests { #expect(result.timedOut == true) } + @Test func shellExecutorDrainsStdoutAndStderr() async { + let script = """ + i=0 + while [ $i -lt 2000 ]; do + echo "stdout-$i" + echo "stderr-$i" 1>&2 + i=$((i+1)) + done + """ + let result = await ShellExecutor.runDetailed( + command: ["/bin/sh", "-c", script], + cwd: nil, + env: nil, + timeout: 2) + #expect(result.success == true) + #expect(result.stdout.contains("stdout-1999")) + #expect(result.stderr.contains("stderr-1999")) + } + @Test func pairedNodesStorePersists() async throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)