Skip to content

Commit b352cb2

Browse files
authored
fix(ios): guard websocket ping continuation (#88231)
Merged via squash. Prepared head SHA: b4cee97 Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman
1 parent b9933b2 commit b352cb2

2 files changed

Lines changed: 77 additions & 1 deletion

File tree

apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ public protocol WebSocketTasking: AnyObject {
1414

1515
extension URLSessionWebSocketTask: WebSocketTasking {}
1616

17+
private final class WebSocketPingContinuationGate: @unchecked Sendable {
18+
private let lock = NSLock()
19+
private var didResume = false
20+
21+
func resumeOnce(_ resume: () -> Void) {
22+
self.lock.lock()
23+
if self.didResume {
24+
self.lock.unlock()
25+
return
26+
}
27+
self.didResume = true
28+
self.lock.unlock()
29+
resume()
30+
}
31+
}
32+
1733
public struct WebSocketTaskBox: @unchecked Sendable {
1834
public let task: any WebSocketTasking
1935
public init(task: any WebSocketTasking) {
@@ -48,8 +64,13 @@ public struct WebSocketTaskBox: @unchecked Sendable {
4864

4965
public func sendPing() async throws {
5066
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
67+
let gate = WebSocketPingContinuationGate()
5168
self.task.sendPing { error in
52-
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
69+
// URLSession can race ping callbacks with cancellation; only the first
70+
// pong result owns this checked continuation or Swift traps the app.
71+
gate.resumeOnce {
72+
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
73+
}
5374
}
5475
}
5576
}

apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,42 @@ private extension NSLock {
1111
}
1212
}
1313

14+
private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecked Sendable {
15+
private let callbacks: [Error?]
16+
17+
init(callbacks: [Error?]) {
18+
self.callbacks = callbacks
19+
}
20+
21+
var state: URLSessionTask.State { .running }
22+
23+
func resume() {}
24+
25+
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
26+
_ = (closeCode, reason)
27+
}
28+
29+
func send(_ message: URLSessionWebSocketTask.Message) async throws {
30+
_ = message
31+
}
32+
33+
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
34+
for callback in self.callbacks {
35+
pongReceiveHandler(callback)
36+
}
37+
}
38+
39+
func receive() async throws -> URLSessionWebSocketTask.Message {
40+
throw URLError(.badServerResponse)
41+
}
42+
43+
func receive(
44+
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
45+
{
46+
completionHandler(.failure(URLError(.badServerResponse)))
47+
}
48+
}
49+
1450
private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
1551
private let lock = NSLock()
1652
private let helloAuth: [String: Any]?
@@ -193,6 +229,25 @@ private actor SeqGapProbe {
193229

194230
@Suite(.serialized)
195231
struct GatewayNodeSessionTests {
232+
@Test
233+
func websocketPingIgnoresDuplicateSuccessCallbacks() async throws {
234+
let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil])
235+
try await WebSocketTaskBox(task: task).sendPing()
236+
}
237+
238+
@Test
239+
func websocketPingIgnoresDuplicateCallbacksAfterFirstError() async throws {
240+
let firstError = URLError(.networkConnectionLost)
241+
let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil])
242+
243+
do {
244+
try await WebSocketTaskBox(task: task).sendPing()
245+
Issue.record("sendPing unexpectedly succeeded")
246+
} catch let error as URLError {
247+
#expect(error.code == firstError.code)
248+
}
249+
}
250+
196251
@Test
197252
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
198253
let tempDir = FileManager.default.temporaryDirectory

0 commit comments

Comments
 (0)