Skip to content

Commit d40f8c4

Browse files
mbelinkyclaude
andcommitted
fix(ios): harden watch messaging activation concurrency
Co-authored-by: Rocuts <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent 4c6dec8 commit d40f8c4

File tree

2 files changed

+40
-31
lines changed

2 files changed

+40
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
1616
- Docs/security hardening guidance: document Docker `DOCKER-USER` + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan.
1717
- iOS/Voice timing safety: guard system speech start/finish callbacks to the active utterance to avoid misattributed start events during rapid stop/restart cycles. (#33304) thanks @mbelinky; original implementation direction by @ngutman.
1818
- iOS/Talk incremental speech pacing: allow long punctuation-free assistant chunks to start speaking at safe whitespace boundaries so voice responses begin sooner instead of waiting for terminal punctuation. (#33305) thanks @mbelinky; original implementation by @ngutman.
19+
- iOS/Watch reply reliability: make watch session activation waiters robust under concurrent requests so status/send calls no longer hang intermittently, and align delegate callbacks with Swift 6 actor safety. (#33306) thanks @mbelinky; original implementation by @Rocuts.
1920
- Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.
2021
- Gateway/session agent discovery: include disk-scanned agent IDs in `listConfiguredAgentIds` even when `agents.list` is configured, so disk-only/ACP agent sessions remain visible in gateway session aggregation and listings. (#32831) thanks @Sid-Qin.
2122
- Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow.

apps/ios/Sources/Services/WatchMessagingService.swift

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ enum WatchMessagingError: LocalizedError {
2020
}
2121
}
2222

23-
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
24-
private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
23+
@MainActor
24+
final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServicing {
25+
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
2526
private let session: WCSession?
26-
private let replyHandlerLock = NSLock()
27+
private var pendingActivationContinuations: [CheckedContinuation<Void, Never>] = []
2728
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
2829

2930
override init() {
@@ -39,11 +40,11 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
3940
}
4041
}
4142

42-
static func isSupportedOnDevice() -> Bool {
43+
nonisolated static func isSupportedOnDevice() -> Bool {
4344
WCSession.isSupported()
4445
}
4546

46-
static func currentStatusSnapshot() -> WatchMessagingStatus {
47+
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
4748
guard WCSession.isSupported() else {
4849
return WatchMessagingStatus(
4950
supported: false,
@@ -70,9 +71,7 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
7071
}
7172

7273
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
73-
self.replyHandlerLock.lock()
7474
self.replyHandler = handler
75-
self.replyHandlerLock.unlock()
7675
}
7776

7877
func sendNotification(
@@ -161,19 +160,15 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
161160
}
162161

163162
private func emitReply(_ event: WatchQuickReplyEvent) {
164-
let handler: ((WatchQuickReplyEvent) -> Void)?
165-
self.replyHandlerLock.lock()
166-
handler = self.replyHandler
167-
self.replyHandlerLock.unlock()
168-
handler?(event)
163+
self.replyHandler?(event)
169164
}
170165

171-
private static func nonEmpty(_ value: String?) -> String? {
166+
nonisolated private static func nonEmpty(_ value: String?) -> String? {
172167
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
173168
return trimmed.isEmpty ? nil : trimmed
174169
}
175170

176-
private static func parseQuickReplyPayload(
171+
nonisolated private static func parseQuickReplyPayload(
177172
_ payload: [String: Any],
178173
transport: String) -> WatchQuickReplyEvent?
179174
{
@@ -205,13 +200,12 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
205200
guard let session = self.session else { return }
206201
if session.activationState == .activated { return }
207202
session.activate()
208-
for _ in 0..<8 {
209-
if session.activationState == .activated { return }
210-
try? await Task.sleep(nanoseconds: 100_000_000)
203+
await withCheckedContinuation { continuation in
204+
self.pendingActivationContinuations.append(continuation)
211205
}
212206
}
213207

214-
private static func status(for session: WCSession) -> WatchMessagingStatus {
208+
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
215209
WatchMessagingStatus(
216210
supported: true,
217211
paired: session.isPaired,
@@ -220,7 +214,7 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
220214
activationState: activationStateLabel(session.activationState))
221215
}
222216

223-
private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
217+
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
224218
switch state {
225219
case .notActivated:
226220
"notActivated"
@@ -235,32 +229,42 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
235229
}
236230

237231
extension WatchMessagingService: WCSessionDelegate {
238-
func session(
232+
nonisolated func session(
239233
_ session: WCSession,
240234
activationDidCompleteWith activationState: WCSessionActivationState,
241235
error: (any Error)?)
242236
{
243237
if let error {
244238
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
245-
return
239+
} else {
240+
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
241+
}
242+
// Always resume all waiters so callers never hang, even on error.
243+
Task { @MainActor in
244+
let waiters = self.pendingActivationContinuations
245+
self.pendingActivationContinuations.removeAll()
246+
for continuation in waiters {
247+
continuation.resume()
248+
}
246249
}
247-
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
248250
}
249251

250-
func sessionDidBecomeInactive(_ session: WCSession) {}
252+
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
251253

252-
func sessionDidDeactivate(_ session: WCSession) {
254+
nonisolated func sessionDidDeactivate(_ session: WCSession) {
253255
session.activate()
254256
}
255257

256-
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
258+
nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) {
257259
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
258260
return
259261
}
260-
self.emitReply(event)
262+
Task { @MainActor in
263+
self.emitReply(event)
264+
}
261265
}
262266

263-
func session(
267+
nonisolated func session(
264268
_: WCSession,
265269
didReceiveMessage message: [String: Any],
266270
replyHandler: @escaping ([String: Any]) -> Void)
@@ -270,15 +274,19 @@ extension WatchMessagingService: WCSessionDelegate {
270274
return
271275
}
272276
replyHandler(["ok": true])
273-
self.emitReply(event)
277+
Task { @MainActor in
278+
self.emitReply(event)
279+
}
274280
}
275281

276-
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
282+
nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
277283
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
278284
return
279285
}
280-
self.emitReply(event)
286+
Task { @MainActor in
287+
self.emitReply(event)
288+
}
281289
}
282290

283-
func sessionReachabilityDidChange(_ session: WCSession) {}
291+
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {}
284292
}

0 commit comments

Comments
 (0)