fix(ios): harden watch messaging activation concurrency (#33306)
Merged via squash. Prepared head SHA: d40f8c4afbd6ddf38548c9b0c6ac6ac4359f2e54 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ enum WatchMessagingError: LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
@MainActor
|
||||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServicing {
|
||||||
|
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||||
private let session: WCSession?
|
private let session: WCSession?
|
||||||
private let replyHandlerLock = NSLock()
|
private var pendingActivationContinuations: [CheckedContinuation<Void, Never>] = []
|
||||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
@@ -39,11 +40,11 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isSupportedOnDevice() -> Bool {
|
nonisolated static func isSupportedOnDevice() -> Bool {
|
||||||
WCSession.isSupported()
|
WCSession.isSupported()
|
||||||
}
|
}
|
||||||
|
|
||||||
static func currentStatusSnapshot() -> WatchMessagingStatus {
|
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||||
guard WCSession.isSupported() else {
|
guard WCSession.isSupported() else {
|
||||||
return WatchMessagingStatus(
|
return WatchMessagingStatus(
|
||||||
supported: false,
|
supported: false,
|
||||||
@@ -70,9 +71,7 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||||
self.replyHandlerLock.lock()
|
|
||||||
self.replyHandler = handler
|
self.replyHandler = handler
|
||||||
self.replyHandlerLock.unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendNotification(
|
func sendNotification(
|
||||||
@@ -161,19 +160,15 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func emitReply(_ event: WatchQuickReplyEvent) {
|
private func emitReply(_ event: WatchQuickReplyEvent) {
|
||||||
let handler: ((WatchQuickReplyEvent) -> Void)?
|
self.replyHandler?(event)
|
||||||
self.replyHandlerLock.lock()
|
|
||||||
handler = self.replyHandler
|
|
||||||
self.replyHandlerLock.unlock()
|
|
||||||
handler?(event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func nonEmpty(_ value: String?) -> String? {
|
nonisolated private static func nonEmpty(_ value: String?) -> String? {
|
||||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func parseQuickReplyPayload(
|
nonisolated private static func parseQuickReplyPayload(
|
||||||
_ payload: [String: Any],
|
_ payload: [String: Any],
|
||||||
transport: String) -> WatchQuickReplyEvent?
|
transport: String) -> WatchQuickReplyEvent?
|
||||||
{
|
{
|
||||||
@@ -205,13 +200,12 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
|||||||
guard let session = self.session else { return }
|
guard let session = self.session else { return }
|
||||||
if session.activationState == .activated { return }
|
if session.activationState == .activated { return }
|
||||||
session.activate()
|
session.activate()
|
||||||
for _ in 0..<8 {
|
await withCheckedContinuation { continuation in
|
||||||
if session.activationState == .activated { return }
|
self.pendingActivationContinuations.append(continuation)
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func status(for session: WCSession) -> WatchMessagingStatus {
|
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||||
WatchMessagingStatus(
|
WatchMessagingStatus(
|
||||||
supported: true,
|
supported: true,
|
||||||
paired: session.isPaired,
|
paired: session.isPaired,
|
||||||
@@ -220,7 +214,7 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
|||||||
activationState: activationStateLabel(session.activationState))
|
activationState: activationStateLabel(session.activationState))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||||
switch state {
|
switch state {
|
||||||
case .notActivated:
|
case .notActivated:
|
||||||
"notActivated"
|
"notActivated"
|
||||||
@@ -235,32 +229,42 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension WatchMessagingService: WCSessionDelegate {
|
extension WatchMessagingService: WCSessionDelegate {
|
||||||
func session(
|
nonisolated func session(
|
||||||
_ session: WCSession,
|
_ session: WCSession,
|
||||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||||
error: (any Error)?)
|
error: (any Error)?)
|
||||||
{
|
{
|
||||||
if let error {
|
if let error {
|
||||||
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
||||||
return
|
} else {
|
||||||
|
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
||||||
|
}
|
||||||
|
// Always resume all waiters so callers never hang, even on error.
|
||||||
|
Task { @MainActor in
|
||||||
|
let waiters = self.pendingActivationContinuations
|
||||||
|
self.pendingActivationContinuations.removeAll()
|
||||||
|
for continuation in waiters {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||||
|
|
||||||
func sessionDidDeactivate(_ session: WCSession) {
|
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||||
session.activate()
|
session.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
|
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.emitReply(event)
|
Task { @MainActor in
|
||||||
|
self.emitReply(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func session(
|
nonisolated func session(
|
||||||
_: WCSession,
|
_: WCSession,
|
||||||
didReceiveMessage message: [String: Any],
|
didReceiveMessage message: [String: Any],
|
||||||
replyHandler: @escaping ([String: Any]) -> Void)
|
replyHandler: @escaping ([String: Any]) -> Void)
|
||||||
@@ -270,15 +274,19 @@ extension WatchMessagingService: WCSessionDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
replyHandler(["ok": true])
|
replyHandler(["ok": true])
|
||||||
self.emitReply(event)
|
Task { @MainActor in
|
||||||
|
self.emitReply(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||||
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
|
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.emitReply(event)
|
Task { @MainActor in
|
||||||
|
self.emitReply(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionReachabilityDidChange(_ session: WCSession) {}
|
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user