From fa4cd811a17886b5f078296c492b29c3a874cc70 Mon Sep 17 00:00:00 2001 From: Mathias Caldas Date: Sat, 21 Mar 2026 03:41:17 +0200 Subject: [PATCH 1/2] fix(ios): prevent CheckedContinuation double-resume crash in WebSocket ping URLSessionWebSocketTask.sendPing can invoke the pong handler more than once when the task is cancelled mid-flight (observed on iOS 18+). Wrap in OneShotContinuation with NSLock guard to absorb duplicate calls. --- .../Sources/OpenClawKit/GatewayChannel.swift | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 2c3da84af68..1102101940d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -2,6 +2,25 @@ 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() + let cont = self.continuation + self.continuation = nil + lock.unlock() + guard let cont else { return } + ThrowingContinuationSupport.resumeVoid(cont, error: error) + } +} + public protocol WebSocketTasking: AnyObject { var state: URLSessionTask.State { get } func resume() @@ -44,8 +63,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) } } } From 801dbb508a6d2c5c473c894938c7a4c8067988ac Mon Sep 17 00:00:00 2001 From: Mathias Caldas Date: Sat, 21 Mar 2026 03:58:02 +0200 Subject: [PATCH 2/2] style: use defer for lock release, add comment on silent drop path Address review feedback: use idiomatic defer { lock.unlock() } and document that the guard-nil branch is reachable in production (iOS 18+ double pong handler invocation). --- .../OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 1102101940d..94f3614f1ab 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -13,10 +13,14 @@ private final class OneShotContinuation: @unchecked Sendable { func resume(error: Error?) { lock.lock() + defer { lock.unlock() } let cont = self.continuation self.continuation = nil - lock.unlock() - guard let cont else { return } + 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) } }