diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 2c3da84af68..94f3614f1ab 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -2,6 +2,29 @@ import OpenClawProtocol import Foundation import OSLog +/// Thread-safe one-shot wrapper around CheckedContinuation to prevent double-resume crashes. +private final class OneShotContinuation: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume(error: Error?) { + lock.lock() + defer { lock.unlock() } + let cont = self.continuation + self.continuation = nil + guard let cont else { + // URLSessionWebSocketTask fired pong handler more than once (iOS 18+ race). + // The continuation was already resumed; silently drop the duplicate. + return + } + ThrowingContinuationSupport.resumeVoid(cont, error: error) + } +} + public protocol WebSocketTasking: AnyObject { var state: URLSessionTask.State { get } func resume() @@ -44,8 +67,12 @@ public struct WebSocketTaskBox: @unchecked Sendable { public func sendPing() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // URLSessionWebSocketTask.sendPing can invoke the handler more than once when + // the task is cancelled mid-flight (observed on iOS 18+). Guard with a one-shot + // wrapper to avoid a CheckedContinuation double-resume crash. + let once = OneShotContinuation(continuation) self.task.sendPing { error in - ThrowingContinuationSupport.resumeVoid(continuation, error: error) + once.resume(error: error) } } }