From b352cb2d8e7f25a582f06ffe234fdc5229cb49f2 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sat, 30 May 2026 07:56:34 +0300 Subject: [PATCH] fix(ios): guard websocket ping continuation (#88231) Merged via squash. Prepared head SHA: b4cee97b8a1d13a1350055319e1e93529fcbe1d4 Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman --- .../Sources/OpenClawKit/GatewayChannel.swift | 23 +++++++- .../GatewayNodeSessionTests.swift | 55 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 8028dd61410..46b4e302f5a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -14,6 +14,22 @@ public protocol WebSocketTasking: AnyObject { extension URLSessionWebSocketTask: WebSocketTasking {} +private final class WebSocketPingContinuationGate: @unchecked Sendable { + private let lock = NSLock() + private var didResume = false + + func resumeOnce(_ resume: () -> Void) { + self.lock.lock() + if self.didResume { + self.lock.unlock() + return + } + self.didResume = true + self.lock.unlock() + resume() + } +} + public struct WebSocketTaskBox: @unchecked Sendable { public let task: any WebSocketTasking public init(task: any WebSocketTasking) { @@ -48,8 +64,13 @@ public struct WebSocketTaskBox: @unchecked Sendable { public func sendPing() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let gate = WebSocketPingContinuationGate() self.task.sendPing { error in - ThrowingContinuationSupport.resumeVoid(continuation, error: error) + // URLSession can race ping callbacks with cancellation; only the first + // pong result owns this checked continuation or Swift traps the app. + gate.resumeOnce { + ThrowingContinuationSupport.resumeVoid(continuation, error: error) + } } } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index f9dac81baff..c8fef5aeb8a 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -11,6 +11,42 @@ private extension NSLock { } } +private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecked Sendable { + private let callbacks: [Error?] + + init(callbacks: [Error?]) { + self.callbacks = callbacks + } + + var state: URLSessionTask.State { .running } + + func resume() {} + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + _ = (closeCode, reason) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + _ = message + } + + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + for callback in self.callbacks { + pongReceiveHandler(callback) + } + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + throw URLError(.badServerResponse) + } + + func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + completionHandler(.failure(URLError(.badServerResponse))) + } +} + private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable { private let lock = NSLock() private let helloAuth: [String: Any]? @@ -193,6 +229,25 @@ private actor SeqGapProbe { @Suite(.serialized) struct GatewayNodeSessionTests { + @Test + func websocketPingIgnoresDuplicateSuccessCallbacks() async throws { + let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil]) + try await WebSocketTaskBox(task: task).sendPing() + } + + @Test + func websocketPingIgnoresDuplicateCallbacksAfterFirstError() async throws { + let firstError = URLError(.networkConnectionLost) + let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil]) + + do { + try await WebSocketTaskBox(task: task).sendPing() + Issue.record("sendPing unexpectedly succeeded") + } catch let error as URLError { + #expect(error.code == firstError.code) + } + } + @Test func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws { let tempDir = FileManager.default.temporaryDirectory