From 91742e4872d5fb4546ebf32a71397608b13a3220 Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:22:15 -0700 Subject: [PATCH 1/5] fix: extend bridge send timeout --- Sources/IMsgCore/IMsgBridgeClient.swift | 5 ++- Sources/IMsgCore/IMsgBridgeProtocol.swift | 15 +++++++- .../IMsgBridgeProtocolTests.swift | 37 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/Sources/IMsgCore/IMsgBridgeClient.swift b/Sources/IMsgCore/IMsgBridgeClient.swift index 789948d..b899c5c 100644 --- a/Sources/IMsgCore/IMsgBridgeClient.swift +++ b/Sources/IMsgCore/IMsgBridgeClient.swift @@ -41,15 +41,16 @@ public final class IMsgBridgeClient: @unchecked Sendable { public func invoke( action: BridgeAction, params: [String: Any] = [:], - timeout: TimeInterval = IMsgBridgeProtocol.defaultResponseTimeout + timeout: TimeInterval? = nil ) async throws -> [String: Any] { + let effectiveTimeout = timeout ?? IMsgBridgeProtocol.defaultResponseTimeout(for: action) if useLegacyIPC { try launcher.ensureRunning() return try await invokeLegacy(action: action, params: params) } try launcher.ensureLaunched() - return try await invokeV2(action: action, params: params, timeout: timeout) + return try await invokeV2(action: action, params: params, timeout: effectiveTimeout) } // MARK: - v2 path diff --git a/Sources/IMsgCore/IMsgBridgeProtocol.swift b/Sources/IMsgCore/IMsgBridgeProtocol.swift index 1bb06ea..412a379 100644 --- a/Sources/IMsgCore/IMsgBridgeProtocol.swift +++ b/Sources/IMsgCore/IMsgBridgeProtocol.swift @@ -22,8 +22,21 @@ public enum IMsgBridgeProtocol { public static let rotatedEventsFileName: String = ".imsg-events.jsonl.1" public static let eventsRotationBytes: Int = 1 * 1024 * 1024 - /// Default per-request timeout for synchronous RPC waits. + /// Default per-request timeout for synchronous non-send RPC waits. public static let defaultResponseTimeout: TimeInterval = 10.0 + + /// Default timeout for send-style bridge waits. macOS 26 private sends can + /// stall for well over the probe window before Messages.app returns. + public static let defaultSendResponseTimeout: TimeInterval = 150.0 + + public static func defaultResponseTimeout(for action: BridgeAction) -> TimeInterval { + switch action { + case .sendMessage, .sendMultipart, .sendAttachment, .sendPoll, .sendReaction: + return defaultSendResponseTimeout + default: + return defaultResponseTimeout + } + } } /// All action verbs exposed by the v2 bridge. Names match the BlueBubbles diff --git a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift index 171cb85..1b33cb5 100644 --- a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift +++ b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift @@ -82,4 +82,41 @@ struct IMsgBridgeProtocolTests { #expect(response.success == false) #expect(response.error == "Chat not found") } + + @Test + func bridgeProtocolUsesLongerDefaultForSendActions() { + #expect(IMsgBridgeProtocol.defaultResponseTimeout == 10.0) + #expect(IMsgBridgeProtocol.defaultSendResponseTimeout == 150.0) + + for action in [ + BridgeAction.sendMessage, + .sendMultipart, + .sendAttachment, + .sendPoll, + .sendReaction, + ] { + #expect( + IMsgBridgeProtocol.defaultResponseTimeout(for: action) + == IMsgBridgeProtocol.defaultSendResponseTimeout + ) + } + } + + @Test + func bridgeProtocolKeepsShortDefaultForNonSendActions() { + for action in [ + BridgeAction.status, + .typing, + .read, + .editMessage, + .unsendMessage, + .deleteMessage, + .notifyAnyways, + ] { + #expect( + IMsgBridgeProtocol.defaultResponseTimeout(for: action) + == IMsgBridgeProtocol.defaultResponseTimeout + ) + } + } } From 1533ad1f5a48e609d1094c666306b1cb19fb717c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 08:58:55 +0100 Subject: [PATCH 2/5] docs(changelog): note bridge send timeout --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b738c2a..2152da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Advanced IMCore - fix: defer injected bridge bootstrap until after Messages startup so macOS 26 dyld constructor ordering cannot touch ObjC/Foundation/IMCore before the process is ready (#138, thanks @omarshahine). +- fix: let bridge-backed send actions wait longer for slow Messages private-send completions while keeping short timeouts for probe and mutation calls (#139, thanks @omarshahine). ## 0.11.0 - 2026-05-31 From 322669a0e712c0ff71b8b93f666a2fc98c2d5e7f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 09:02:04 +0100 Subject: [PATCH 3/5] fix: preserve bridge client timeout API --- Sources/IMsgCore/IMsgBridgeClient.swift | 19 ++++++++++++++++--- .../IMsgBridgeProtocolTests.swift | 9 +++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Sources/IMsgCore/IMsgBridgeClient.swift b/Sources/IMsgCore/IMsgBridgeClient.swift index b899c5c..9dcee03 100644 --- a/Sources/IMsgCore/IMsgBridgeClient.swift +++ b/Sources/IMsgCore/IMsgBridgeClient.swift @@ -38,19 +38,32 @@ public final class IMsgBridgeClient: @unchecked Sendable { /// Invoke a v2 bridge action and return its `data` payload on success. /// Legacy single-file IPC is only used when explicitly requested through /// `IMSG_BRIDGE_LEGACY_IPC=1`. + public func invoke( + action: BridgeAction, + params: [String: Any] = [:] + ) async throws -> [String: Any] { + try await invoke( + action: action, + params: params, + timeout: IMsgBridgeProtocol.defaultResponseTimeout(for: action) + ) + } + + /// Invoke a v2 bridge action with an explicit timeout. + /// Legacy single-file IPC is only used when explicitly requested through + /// `IMSG_BRIDGE_LEGACY_IPC=1`. public func invoke( action: BridgeAction, params: [String: Any] = [:], - timeout: TimeInterval? = nil + timeout: TimeInterval ) async throws -> [String: Any] { - let effectiveTimeout = timeout ?? IMsgBridgeProtocol.defaultResponseTimeout(for: action) if useLegacyIPC { try launcher.ensureRunning() return try await invokeLegacy(action: action, params: params) } try launcher.ensureLaunched() - return try await invokeV2(action: action, params: params, timeout: effectiveTimeout) + return try await invokeV2(action: action, params: params, timeout: timeout) } // MARK: - v2 path diff --git a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift index 1b33cb5..c1cf500 100644 --- a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift +++ b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import IMsgCore @@ -119,4 +120,12 @@ struct IMsgBridgeProtocolTests { ) } } + + @Test + func bridgeClientKeepsExplicitTimeoutInvokeSignature() { + let explicitTimeoutInvoke: + (BridgeAction, [String: Any], TimeInterval) async throws -> [String: Any] = + IMsgBridgeClient.shared.invoke + _ = explicitTimeoutInvoke + } } From 5c1595850aeeea9c804bb2fcdb3e093a1e38de06 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 09:05:20 +0100 Subject: [PATCH 4/5] fix: extend bridge timeout to initial chat creation --- CHANGELOG.md | 2 +- Sources/IMsgCore/IMsgBridgeProtocol.swift | 3 ++- Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2152da7..6097d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Advanced IMCore - fix: defer injected bridge bootstrap until after Messages startup so macOS 26 dyld constructor ordering cannot touch ObjC/Foundation/IMCore before the process is ready (#138, thanks @omarshahine). -- fix: let bridge-backed send actions wait longer for slow Messages private-send completions while keeping short timeouts for probe and mutation calls (#139, thanks @omarshahine). +- fix: let bridge-backed sends and initial chat creation wait longer for slow Messages private-send completions while keeping short timeouts for probe and mutation calls (#139, thanks @omarshahine). ## 0.11.0 - 2026-05-31 diff --git a/Sources/IMsgCore/IMsgBridgeProtocol.swift b/Sources/IMsgCore/IMsgBridgeProtocol.swift index 412a379..5c3a39c 100644 --- a/Sources/IMsgCore/IMsgBridgeProtocol.swift +++ b/Sources/IMsgCore/IMsgBridgeProtocol.swift @@ -31,7 +31,8 @@ public enum IMsgBridgeProtocol { public static func defaultResponseTimeout(for action: BridgeAction) -> TimeInterval { switch action { - case .sendMessage, .sendMultipart, .sendAttachment, .sendPoll, .sendReaction: + case .sendMessage, .sendMultipart, .sendAttachment, .sendPoll, .sendReaction, + .createChat: return defaultSendResponseTimeout default: return defaultResponseTimeout diff --git a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift index c1cf500..d0b2441 100644 --- a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift +++ b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift @@ -95,6 +95,7 @@ struct IMsgBridgeProtocolTests { .sendAttachment, .sendPoll, .sendReaction, + .createChat, ] { #expect( IMsgBridgeProtocol.defaultResponseTimeout(for: action) From c8296e65f781b2b95f14bf6958897f24ef67d0eb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 09:08:47 +0100 Subject: [PATCH 5/5] fix: apply bridge send timeout to legacy IPC --- Sources/IMsgCore/IMsgBridgeClient.swift | 11 ++++-- Sources/IMsgCore/MessagesLauncher.swift | 38 +++++++++++++++++-- .../IMsgBridgeProtocolTests.swift | 10 +++++ 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/Sources/IMsgCore/IMsgBridgeClient.swift b/Sources/IMsgCore/IMsgBridgeClient.swift index 9dcee03..10dc261 100644 --- a/Sources/IMsgCore/IMsgBridgeClient.swift +++ b/Sources/IMsgCore/IMsgBridgeClient.swift @@ -59,7 +59,7 @@ public final class IMsgBridgeClient: @unchecked Sendable { ) async throws -> [String: Any] { if useLegacyIPC { try launcher.ensureRunning() - return try await invokeLegacy(action: action, params: params) + return try await invokeLegacy(action: action, params: params, timeout: timeout) } try launcher.ensureLaunched() @@ -125,10 +125,15 @@ public final class IMsgBridgeClient: @unchecked Sendable { private func invokeLegacy( action: BridgeAction, - params: [String: Any] + params: [String: Any], + timeout: TimeInterval ) async throws -> [String: Any] { do { - let raw = try await launcher.sendCommand(action: action.rawValue, params: params) + let raw = try await launcher.sendCommand( + action: action.rawValue, + params: params, + timeout: timeout + ) let response = try BridgeResponse.parse(raw) if response.success { return response.data diff --git a/Sources/IMsgCore/MessagesLauncher.swift b/Sources/IMsgCore/MessagesLauncher.swift index a5ef635..beb11bb 100644 --- a/Sources/IMsgCore/MessagesLauncher.swift +++ b/Sources/IMsgCore/MessagesLauncher.swift @@ -72,7 +72,11 @@ import Foundation return false } do { - let response = try sendCommandSync(action: "ping", params: [:]) + let response = try sendCommandSync( + action: "ping", + params: [:], + timeout: IMsgBridgeProtocol.defaultResponseTimeout + ) return response["success"] as? Bool == true } catch { return false @@ -171,6 +175,17 @@ import Foundation /// Send a command asynchronously. public func sendCommand( action: String, params: [String: Any] + ) async throws -> [String: Any] { + try await sendCommand( + action: action, + params: params, + timeout: IMsgBridgeProtocol.defaultResponseTimeout + ) + } + + /// Send a command asynchronously with an explicit response timeout. + public func sendCommand( + action: String, params: [String: Any], timeout: TimeInterval ) async throws -> [String: Any] { try ensureRunning() // Serialize params to JSON data to cross the Sendable boundary safely @@ -182,7 +197,11 @@ import Foundation let deserializedParams = (try? JSONSerialization.jsonObject(with: paramsData, options: [])) as? [String: Any] ?? [:] - let response = try self.sendCommandSync(action: action, params: deserializedParams) + let response = try self.sendCommandSync( + action: action, + params: deserializedParams, + timeout: timeout + ) continuation.resume(returning: response) } catch { continuation.resume(throwing: error) @@ -273,7 +292,7 @@ import Foundation } private func sendCommandSync( - action: String, params: [String: Any] + action: String, params: [String: Any], timeout: TimeInterval ) throws -> [String: Any] { lock.lock() defer { lock.unlock() } @@ -287,7 +306,7 @@ import Foundation let jsonData = try JSONSerialization.data(withJSONObject: command, options: []) try jsonData.write(to: URL(fileURLWithPath: commandFile)) - let deadline = Date().addingTimeInterval(10.0) + let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { Thread.sleep(forTimeInterval: 0.05) @@ -342,8 +361,19 @@ import Foundation public func killMessages() {} public func sendCommand(action: String, params: [String: Any]) async throws -> [String: Any] { + try await sendCommand( + action: action, + params: params, + timeout: IMsgBridgeProtocol.defaultResponseTimeout + ) + } + + public func sendCommand( + action: String, params: [String: Any], timeout: TimeInterval + ) async throws -> [String: Any] { _ = action _ = params + _ = timeout throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.") } diff --git a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift index d0b2441..3c8f064 100644 --- a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift +++ b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift @@ -129,4 +129,14 @@ struct IMsgBridgeProtocolTests { IMsgBridgeClient.shared.invoke _ = explicitTimeoutInvoke } + + @Test + func messagesLauncherKeepsLegacyCommandSignatureAndAllowsExplicitTimeout() { + let defaultTimeoutSend: (String, [String: Any]) async throws -> [String: Any] = + MessagesLauncher.shared.sendCommand + let explicitTimeoutSend: (String, [String: Any], TimeInterval) async throws -> [String: Any] = + MessagesLauncher.shared.sendCommand + _ = defaultTimeoutSend + _ = explicitTimeoutSend + } }