From 6df57d963366e053d5b9a9f649d553b4177b5798 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:28:27 +0000 Subject: [PATCH] iOS Security Stack 2/5: Concurrency Locks (#33241) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: b99ad804fbcc20bfb3042ac1da9050a7175f009c Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + .../ios/Sources/Camera/CameraController.swift | 29 ++++++++++----- .../Gateway/GatewayConnectionController.swift | 35 +++++++++++++------ 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e097a5aee8f..6d514f87989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf - macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman. - iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky. +- iOS/Concurrency stability: replace risky shared-state access in camera and gateway connection paths with lock-protected access patterns to reduce crash risk under load. (#33241) thanks @mbelinky. - Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin. - Telegram/plugin outbound hook parity: run `message_sending` + `message_sent` in Telegram reply delivery, include reply-path hook metadata (`mediaUrls`, `threadId`), and report `message_sent.success=false` when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee. - Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras. diff --git a/apps/ios/Sources/Camera/CameraController.swift b/apps/ios/Sources/Camera/CameraController.swift index 115f36346dc..6b7a0db892c 100644 --- a/apps/ios/Sources/Camera/CameraController.swift +++ b/apps/ios/Sources/Camera/CameraController.swift @@ -1,6 +1,7 @@ import AVFoundation import OpenClawKit import Foundation +import os actor CameraController { struct CameraDeviceInfo: Codable, Sendable { @@ -260,7 +261,7 @@ actor CameraController { private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { private let continuation: CheckedContinuation - private var didResume = false + private let resumed = OSAllocatedUnfairLock(initialState: false) init(_ continuation: CheckedContinuation) { self.continuation = continuation @@ -271,8 +272,12 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat didFinishProcessingPhoto photo: AVCapturePhoto, error: Error? ) { - guard !self.didResume else { return } - self.didResume = true + let alreadyResumed = self.resumed.withLock { old in + let was = old + old = true + return was + } + guard !alreadyResumed else { return } if let error { self.continuation.resume(throwing: error) @@ -301,15 +306,19 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat error: Error? ) { guard let error else { return } - guard !self.didResume else { return } - self.didResume = true + let alreadyResumed = self.resumed.withLock { old in + let was = old + old = true + return was + } + guard !alreadyResumed else { return } self.continuation.resume(throwing: error) } } private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate { private let continuation: CheckedContinuation - private var didResume = false + private let resumed = OSAllocatedUnfairLock(initialState: false) init(_ continuation: CheckedContinuation) { self.continuation = continuation @@ -321,8 +330,12 @@ private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDel from connections: [AVCaptureConnection], error: Error?) { - guard !self.didResume else { return } - self.didResume = true + let alreadyResumed = self.resumed.withLock { old in + let was = old + old = true + return was + } + guard !alreadyResumed else { return } if let error { let ns = error as NSError diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 53e32684988..259768a4df1 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -9,6 +9,7 @@ import Darwin import OpenClawKit import Network import Observation +import os import Photos import ReplayKit import Security @@ -990,12 +991,16 @@ extension GatewayConnectionController { #endif private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @unchecked Sendable { + private struct ProbeState { + var didFinish = false + var session: URLSession? + var task: URLSessionWebSocketTask? + } + private let url: URL private let timeoutSeconds: Double private let onComplete: (String?) -> Void - private var didFinish = false - private var session: URLSession? - private var task: URLSessionWebSocketTask? + private let state = OSAllocatedUnfairLock(initialState: ProbeState()) init(url: URL, timeoutSeconds: Double, onComplete: @escaping (String?) -> Void) { self.url = url @@ -1008,9 +1013,11 @@ private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @u config.timeoutIntervalForRequest = self.timeoutSeconds config.timeoutIntervalForResource = self.timeoutSeconds let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) - self.session = session let task = session.webSocketTask(with: self.url) - self.task = task + self.state.withLock { s in + s.session = session + s.task = task + } task.resume() DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in @@ -1036,12 +1043,18 @@ private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @u } private func finish(_ fingerprint: String?) { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - guard !self.didFinish else { return } - self.didFinish = true - self.task?.cancel(with: .goingAway, reason: nil) - self.session?.invalidateAndCancel() + let (shouldComplete, taskToCancel, sessionToInvalidate) = self.state.withLock { s -> (Bool, URLSessionWebSocketTask?, URLSession?) in + guard !s.didFinish else { return (false, nil, nil) } + s.didFinish = true + let task = s.task + let session = s.session + s.task = nil + s.session = nil + return (true, task, session) + } + guard shouldComplete else { return } + taskToCancel?.cancel(with: .goingAway, reason: nil) + sessionToInvalidate?.invalidateAndCancel() self.onComplete(fingerprint) }