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
This commit is contained in:
Mariano 2026-03-03 16:28:27 +00:00 committed by GitHub
parent 3ee8528b17
commit 6df57d9633
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 46 additions and 19 deletions

View File

@ -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://<peer>.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.

View File

@ -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<Data, Error>
private var didResume = false
private let resumed = OSAllocatedUnfairLock(initialState: false)
init(_ continuation: CheckedContinuation<Data, Error>) {
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<URL, Error>
private var didResume = false
private let resumed = OSAllocatedUnfairLock(initialState: false)
init(_ continuation: CheckedContinuation<URL, Error>) {
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

View File

@ -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)
}