mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(ios): prevent Apple Watch message callbacks from crashing the app (#129921)
This commit is contained in:
committed by
GitHub
parent
378e45a605
commit
94a170089d
@@ -11,27 +11,6 @@ private struct WatchConnectivityTransportCallbacks {
|
||||
var appCommandHandler: (@Sendable (WatchAppCommandEvent) -> Void)?
|
||||
}
|
||||
|
||||
private func sendReachableWatchMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
||||
// WatchConnectivity replies arrive on its own queue. Keep this continuation explicitly
|
||||
// nonisolated so Swift 6 does not inherit a caller actor (for example MainActor) into the
|
||||
// Objective-C callback boundary and trap on the reply callback executor check.
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation<Void, Error>) in
|
||||
session.sendMessage(
|
||||
payload,
|
||||
replyHandler: { reply in
|
||||
do {
|
||||
try requireAcceptedWatchMessageReply(reply)
|
||||
continuation.resume(returning: ())
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
},
|
||||
errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
private nonisolated static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "watch.messaging")
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
@preconcurrency import WatchConnectivity
|
||||
|
||||
enum WatchMessageAcknowledgmentError: LocalizedError {
|
||||
case rejected(String)
|
||||
@@ -23,6 +24,39 @@ func requireAcceptedWatchMessageReply(_ reply: [String: Any]) throws {
|
||||
}
|
||||
}
|
||||
|
||||
final class WatchMessageSendCompletion: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Void, any Error>?
|
||||
|
||||
init(_ continuation: CheckedContinuation<Void, any Error>) {
|
||||
self.continuation = continuation
|
||||
}
|
||||
|
||||
func complete(_ result: Result<Void, any Error>) {
|
||||
let continuation = self.lock.withLock { () -> CheckedContinuation<Void, any Error>? in
|
||||
defer { self.continuation = nil }
|
||||
return self.continuation
|
||||
}
|
||||
continuation?.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReachableWatchMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
||||
// WatchConnectivity callbacks use their own executor and can race despite their
|
||||
// documented exactly-once contract; only the first callback owns this continuation.
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { continuation in
|
||||
let completion = WatchMessageSendCompletion(continuation)
|
||||
session.sendMessage(
|
||||
payload,
|
||||
replyHandler: { reply in
|
||||
completion.complete(Result { try requireAcceptedWatchMessageReply(reply) })
|
||||
},
|
||||
errorHandler: { error in
|
||||
completion.complete(.failure(error))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enum WatchSessionActivationError: LocalizedError {
|
||||
case failed(String)
|
||||
case timedOut
|
||||
|
||||
@@ -17,6 +17,55 @@ struct WatchSessionActivationGateTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `accepted watch reply ignores later transport callbacks`() async throws {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let completion = WatchMessageSendCompletion(continuation)
|
||||
completion.complete(.success(()))
|
||||
completion.complete(.failure(URLError(.timedOut)))
|
||||
completion.complete(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `watch transport error ignores later replies and errors`() async {
|
||||
await #expect(throws: URLError.self) {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let completion = WatchMessageSendCompletion(continuation)
|
||||
completion.complete(.failure(URLError(.notConnectedToInternet)))
|
||||
completion.complete(.success(()))
|
||||
completion.complete(.failure(WatchMessageAcknowledgmentError.rejected("late")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `rejected watch acknowledgment ignores a later transport error`() async {
|
||||
await #expect(throws: WatchMessageAcknowledgmentError.self) {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let completion = WatchMessageSendCompletion(continuation)
|
||||
completion.complete(Result {
|
||||
try requireAcceptedWatchMessageReply(["ok": false, "error": "unsupported_payload"])
|
||||
})
|
||||
completion.complete(.failure(URLError(.timedOut)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `racing watch callbacks complete their continuation exactly once`() async {
|
||||
do {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let completion = WatchMessageSendCompletion(continuation)
|
||||
DispatchQueue.concurrentPerform(iterations: 100) { index in
|
||||
completion.complete(index.isMultiple(of: 2)
|
||||
? .success(())
|
||||
: .failure(URLError(.timedOut)))
|
||||
}
|
||||
}
|
||||
} catch is URLError {
|
||||
// Either terminal callback may win; racing callbacks must never resume twice.
|
||||
} catch {
|
||||
Issue.record("Unexpected watch message failure: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `startup event buffering is ordered and bounded`() {
|
||||
var buffer = WatchMessagingStartupBuffer<String>(maxCount: 3)
|
||||
|
||||
@@ -87,10 +136,6 @@ struct WatchSessionActivationGateTests {
|
||||
contentsOf: iosRoot.appendingPathComponent(
|
||||
"WatchApp/Sources/WatchConnectivityReceiver.swift"),
|
||||
encoding: .utf8)
|
||||
let transportSource = try String(
|
||||
contentsOf: iosRoot.appendingPathComponent(
|
||||
"Sources/Services/WatchConnectivityTransport.swift"),
|
||||
encoding: .utf8)
|
||||
let serviceSource = try String(
|
||||
contentsOf: iosRoot.appendingPathComponent(
|
||||
"Sources/Services/WatchMessagingService.swift"),
|
||||
@@ -104,8 +149,6 @@ struct WatchSessionActivationGateTests {
|
||||
#expect(receiverSource.contains(
|
||||
"acknowledgment: WatchMessageAcknowledgment? = nil) -> Bool"))
|
||||
#expect(receiverSource.contains("guard activationState == .activated else { return }"))
|
||||
#expect(receiverSource.contains("try requireAcceptedWatchMessageReply(reply)"))
|
||||
#expect(transportSource.contains("try requireAcceptedWatchMessageReply(reply)"))
|
||||
let callbackRegistration = try #require(
|
||||
serviceSource.range(of: "self.transport.setAppCommandHandler"))
|
||||
let activation = try #require(serviceSource.range(of: "self.transport.activate()"))
|
||||
|
||||
@@ -92,7 +92,6 @@ struct WatchExecApprovalSnapshotRequestToken: Hashable, Sendable {
|
||||
}
|
||||
|
||||
final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
private typealias MessageSendContinuation = CheckedContinuation<Void, Error>
|
||||
private static let maxAcceptedExecApprovalSnapshotRequests = 32
|
||||
|
||||
private let store: WatchInboxStore
|
||||
@@ -165,7 +164,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
let payload = Self.encodeSnapshotRequestPayload(request)
|
||||
if session.isReachable {
|
||||
do {
|
||||
try await Self.sendMessage(payload, through: session)
|
||||
try await sendReachableWatchMessage(payload, with: session)
|
||||
return token
|
||||
} catch {
|
||||
// Fall through to queued delivery.
|
||||
@@ -261,7 +260,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
var requiresCanonicalReadback = false
|
||||
if session.isReachable {
|
||||
do {
|
||||
try await Self.sendMessage(payload, through: session)
|
||||
try await sendReachableWatchMessage(payload, with: session)
|
||||
return WatchReplySendResult(
|
||||
delivery: .delivered,
|
||||
transport: "sendMessage",
|
||||
@@ -283,22 +282,6 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
requiresCanonicalReadback: requiresCanonicalReadback)
|
||||
}
|
||||
|
||||
private static func sendMessage(_ payload: [String: Any], through session: WCSession) async throws {
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (continuation: MessageSendContinuation) in
|
||||
session.sendMessage(
|
||||
payload,
|
||||
replyHandler: { reply in
|
||||
do {
|
||||
try requireAcceptedWatchMessageReply(reply)
|
||||
continuation.resume(returning: ())
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
},
|
||||
errorHandler: { error in continuation.resume(throwing: error) })
|
||||
}
|
||||
}
|
||||
|
||||
private static func unavailableResult(_ error: any Error) -> WatchReplySendResult {
|
||||
// Activation failed before a payload could be handed to WatchConnectivity.
|
||||
// The closed notSent state lets callers safely offer an immediate retry.
|
||||
|
||||
Reference in New Issue
Block a user