From 4723746b87ed7110d2dd62ab29d1d0fe7285a363 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 8 Jun 2026 18:58:30 -0400 Subject: [PATCH 1/3] fix(connectivity): register WCSession delegate in activate(), not init Constructing a WatchConnectivitySession no longer sets WCSession.default's delegate as a side effect. A throwaway instance (e.g. created and discarded by a SwiftUI @State autoclosure re-evaluation) could otherwise hijack the delegate from the active instance and silently drop all incoming messages. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../WatchConnectivitySession+ConnectivitySession.swift | 5 +++++ .../SundialKitConnectivity/WatchConnectivitySession.swift | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift b/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift index 21dfeee..e1351ca 100644 --- a/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift +++ b/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift @@ -142,6 +142,11 @@ 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.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() { From 42f22f2b32cba2794bcc72c1399aa6eb4ff60d94 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 8 Jun 2026 18:58:30 -0400 Subject: [PATCH 2/3] fix(connectivity): auto-ack dictionary messages to avoid reply timeout didReceiveMessage now calls replyHandler([:]) after forwarding, mirroring the binary didReceiveMessageData path. Without it, every reply-expecting sendMessage times out with WCErrorDomain 7012. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../WatchConnectivitySession+WCSessionDelegate.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift b/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift index 06e9610..fdc62b9 100644 --- a/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift +++ b/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift @@ -102,6 +102,11 @@ let sendableMessage = ConnectivityMessage(forceCasting: message) let handler = unsafeBitCast(replyHandler, 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). Mirrors the binary + // `didReceiveMessageData` path's `replyHandler(Data())`. Consumers needing to + // send a real reply are not supported on this path. + replyHandler([:]) } internal func session( From e9e150e459dbe8012665eae2bbbdb41f718a977f Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Tue, 9 Jun 2026 18:25:39 -0400 Subject: [PATCH 3/3] fix(connectivity): once-guard auto-ack reply handlers Guard the WCSession reply handler in both didReceiveMessage and didReceiveMessageData so a delegate that sends a real reply (via ConnectivityReceiveContext.replyWith) cannot double-invoke the handler alongside the unconditional auto-acknowledgment. The binary path was equally exposed since its delegate bridge forwards the handler to consumers. Also document that activate() is safe to call twice and that only the activated instance owns WCSession.default's delegate. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...nectivitySession+ConnectivitySession.swift | 6 +++ ...onnectivitySession+WCSessionDelegate.swift | 42 +++++++++++++++---- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift b/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift index e1351ca..a5e3854 100644 --- a/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift +++ b/Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift @@ -137,6 +137,12 @@ /// /// 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 { diff --git a/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift b/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift index fdc62b9..48a065e 100644 --- a/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift +++ b/Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift @@ -100,13 +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). Mirrors the binary - // `didReceiveMessageData` path's `replyHandler(Data())`. Consumers needing to - // send a real reply are not supported on this path. - replyHandler([:]) + // immediately instead of timing out (WCErrorDomain 7012). Skipped if the + // delegate already replied. Mirrors the binary `didReceiveMessageData` path. + if !replied { + replyHandler([:]) + } } internal func session( @@ -141,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()) + } } }