mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 11:54:06 +00:00
fix(ios): guard websocket ping continuation (#88231)
Merged via squash.
Prepared head SHA: b4cee97b8a
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
@@ -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<Void, Error>) 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<URLSessionWebSocketTask.Message, Error>) -> 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
|
||||
|
||||
Reference in New Issue
Block a user