Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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())
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading