diff --git a/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift b/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift index 21dfeee..a5e3854 100644 --- a/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift +++ b/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift @@ -137,11 +137,22 @@ /// /// Must be called before any message exchange can occur. /// + /// Registering the WCSession delegate happens here rather than in `init` so that + /// only the activated instance owns `WCSession.default`'s delegate; a throwaway + /// instance can never hijack delivery from the real one. Calling `activate()` more + /// than once on the same instance is safe — re-assigning `session.delegate = self` + /// is a no-op and WCSession tolerates a repeated `activate()`. + /// /// - Throws: `SundialError.sessionNotSupported` if WatchConnectivity is not supported public func activate() throws { guard WCSession.isSupported() else { throw SundialError.sessionNotSupported } + // Register as the WCSession delegate here (not in `init`) so only the + // activated instance owns `WCSession.default`'s delegate. This prevents + // throwaway instances — e.g. created by SwiftUI re-evaluating a `@State` + // autoclosure — from hijacking delivery from the real instance. + session.delegate = self session.activate() } } diff --git a/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift b/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift index 06e9610..48a065e 100644 --- a/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift +++ b/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift @@ -100,8 +100,27 @@ ) { // WatchConnectivity only supports property list types which are inherently Sendable let sendableMessage = ConnectivityMessage(forceCasting: message) - let handler = unsafeBitCast(replyHandler, to: ConnectivityHandler.self) + // Guard the WCSession reply handler so it can fire at most once. The same + // closure is handed to the delegate *and* auto-acknowledged below; a delegate + // that actually replies (e.g. via `ConnectivityReceiveContext.replyWith`) would + // otherwise invoke `replyHandler` twice, which is undefined behavior on the + // sender side. + var replied = false + let safeReply: ([String: Any]) -> Void = { response in + guard !replied else { + return + } + replied = true + replyHandler(response) + } + let handler = unsafeBitCast(safeReply, to: ConnectivityHandler.self) delegate?.session(self, didReceiveMessage: sendableMessage, replyHandler: handler) + // Auto-acknowledge so the sender's reply-expecting `sendMessage` completes + // immediately instead of timing out (WCErrorDomain 7012). Skipped if the + // delegate already replied. Mirrors the binary `didReceiveMessageData` path. + if !replied { + replyHandler([:]) + } } internal func session( @@ -136,9 +155,23 @@ didReceiveMessageData messageData: Data, replyHandler: @escaping (Data) -> Void ) { - let handler = unsafeBitCast(replyHandler, to: (@Sendable (Data) -> Void).self) + // Guard the WCSession reply handler so it can fire at most once. Unlike the + // dictionary path, the `ConnectivityDelegateHandling` bridge forwards this + // handler to consumers, so a delegate that replies plus the unconditional + // auto-acknowledgment below would invoke `replyHandler` twice. + var replied = false + let safeReply: (Data) -> Void = { response in + guard !replied else { + return + } + replied = true + replyHandler(response) + } + let handler = unsafeBitCast(safeReply, to: (@Sendable (Data) -> Void).self) delegate?.session(self, didReceiveMessageData: messageData, replyHandler: handler) - replyHandler(Data()) + if !replied { + replyHandler(Data()) + } } } diff --git a/Sources/SundialKitConnectivity/WatchConnectivitySession.swift b/Sources/SundialKitConnectivity/WatchConnectivitySession.swift index 5600c61..5857975 100644 --- a/Sources/SundialKitConnectivity/WatchConnectivitySession.swift +++ b/Sources/SundialKitConnectivity/WatchConnectivitySession.swift @@ -71,7 +71,11 @@ internal init(session: WCSession) { self.session = session super.init() - session.delegate = self + // The WCSession delegate is registered in `activate()`, not here, so that + // constructing a `WatchConnectivitySession` has no global side effect. A + // throwaway instance (e.g. one created then immediately discarded by a + // SwiftUI `@State` autoclosure re-evaluation) must not hijack + // `WCSession.default`'s delegate from the active, activated instance. } override public convenience init() {