Skip to content

Commit 3a934c8

Browse files
authored
Fix race conditions (#20)
- Fix race condition in WebSocket.messages - Fix race condition in SystemURLSession callbacks - Ensure close code is always reported correctly - Cancelling SystemWebSocket.open throws CancellationError
1 parent a176fc4 commit 3a934c8

5 files changed

Lines changed: 212 additions & 48 deletions

File tree

Sources/WebSocket/SystemURLSession.swift

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,30 @@ private func configuration(with options: WebSocketOptions) -> URLSessionConfigur
5353
return config
5454
}
5555

56-
private final class Delegate: NSObject, URLSessionWebSocketDelegate, Sendable {
56+
final class Delegate: NSObject, URLSessionWebSocketDelegate, Sendable {
5757
private struct Callbacks: Sendable {
5858
let onOpen: @Sendable () async -> Void
5959
let onClose: @Sendable (WebSocketCloseCode, Data?) async -> Void
6060
}
6161

62-
// `Dictionary<ObjectIdentifier(URLWebSocketTask): Callbacks>`
63-
private let state: Locked<[ObjectIdentifier: Callbacks]> = .init([:])
62+
private struct State: Sendable {
63+
var callbacks: [ObjectIdentifier: Callbacks] = [:]
64+
var callbackTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
65+
}
66+
67+
private let state = Locked(State())
6468

6569
func set(
6670
onOpen: @escaping @Sendable () async -> Void,
6771
onClose: @escaping @Sendable (WebSocketCloseCode, Data?) async -> Void,
6872
for taskID: ObjectIdentifier
6973
) {
70-
state.access { $0[taskID] = .init(onOpen: onOpen, onClose: onClose) }
74+
state.access { state in
75+
state.callbacks[taskID] = .init(
76+
onOpen: onOpen,
77+
onClose: onClose
78+
)
79+
}
7180
}
7281

7382
func urlSession(
@@ -76,9 +85,8 @@ private final class Delegate: NSObject, URLSessionWebSocketDelegate, Sendable {
7685
didOpenWithProtocol _: String?
7786
) {
7887
let taskID = ObjectIdentifier(webSocketTask)
79-
80-
if let onOpen = state.access({ $0[taskID]?.onOpen }) {
81-
Task { await onOpen() }
88+
enqueue(for: taskID) { callbacks in
89+
await callbacks.onOpen()
8290
}
8391
}
8492

@@ -89,9 +97,8 @@ private final class Delegate: NSObject, URLSessionWebSocketDelegate, Sendable {
8997
reason: Data?
9098
) {
9199
let taskID = ObjectIdentifier(webSocketTask)
92-
93-
if let onClose = state.access({ $0[taskID]?.onClose }) {
94-
Task { await onClose(WebSocketCloseCode(closeCode), reason) }
100+
enqueue(for: taskID) { callbacks in
101+
await callbacks.onClose(WebSocketCloseCode(closeCode), reason)
95102
}
96103
}
97104

@@ -101,20 +108,36 @@ private final class Delegate: NSObject, URLSessionWebSocketDelegate, Sendable {
101108
didCompleteWithError error: Error?
102109
) {
103110
let taskID = ObjectIdentifier(task)
111+
let closeCode: WebSocketCloseCode = error == nil ? .normalClosure : .abnormalClosure
112+
let reason = error.map { Data($0.localizedDescription.utf8) }
113+
enqueue(for: taskID, removeAfterwards: true) { callbacks in
114+
await callbacks.onClose(closeCode, reason)
115+
}
116+
}
104117

105-
if let onClose = state.access({ $0[taskID]?.onClose }) {
106-
Task { [weak self] in
107-
if let error {
108-
await onClose(
109-
.abnormalClosure,
110-
Data(error.localizedDescription.utf8)
111-
)
112-
} else {
113-
await onClose(.normalClosure, nil)
114-
}
118+
private func enqueue(
119+
for taskID: ObjectIdentifier,
120+
removeAfterwards: Bool = false,
121+
_ operation: @escaping @Sendable (Callbacks) async -> Void
122+
) {
123+
state.access { state in
124+
guard let callbacks = state.callbacks[taskID] else {
125+
return
126+
}
115127

116-
self?.state.access { _ = $0.removeValue(forKey: taskID) }
128+
let previousTask = state.callbackTasks[taskID]
129+
let task = Task { [weak self] in
130+
_ = await previousTask?.result
131+
await operation(callbacks)
132+
133+
guard removeAfterwards else { return }
134+
self?.state.access { state in
135+
_ = state.callbacks.removeValue(forKey: taskID)
136+
_ = state.callbackTasks.removeValue(forKey: taskID)
137+
}
117138
}
139+
140+
state.callbackTasks[taskID] = task
118141
}
119142
}
120143
}

Sources/WebSocket/SystemWebSocket.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ final actor SystemWebSocket: Publisher {
8686
do {
8787
try await didOpen.value
8888
} catch is CancellationError {
89-
doClose(closeCode: .cancelled, reason: Data("cancelled".utf8))
89+
throw CancellationError()
9090
} catch is TimeoutError {
9191
doClose(closeCode: .timeout, reason: Data("timeout".utf8))
9292
throw TimeoutError()
@@ -250,9 +250,12 @@ private extension SystemWebSocket {
250250
}
251251

252252
func doClose(closeCode: WebSocketCloseCode, reason: Data?) {
253+
let close = WebSocketClose(closeCode, reason)
254+
didOpen.fail(WebSocketError(closeCode, reason))
255+
253256
switch state {
254257
case .unopened:
255-
state = .closed(.init(closeCode, reason))
258+
state = .closed(close)
256259

257260
case let .connecting(ws), let .open(ws):
258261
os_log(
@@ -271,7 +274,6 @@ private extension SystemWebSocket {
271274
}
272275
}
273276

274-
let close = WebSocketClose(closeCode, nil)
275277
state = .closed(close)
276278
onClose(close)
277279
didClose?.resolve((code: closeCode, reason: reason))

Sources/WebSocket/WebSocket.swift

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -73,27 +73,15 @@ public extension WebSocket {
7373

7474
/// The WebSocket's received messages as an asynchronous stream.
7575
var messages: AsyncStream<WebSocketMessage> {
76-
let cancellable = Locked<AnyCancellable?>(nil)
77-
78-
return AsyncStream { cont in
79-
func finish() {
80-
cancellable.access { cancellable in
81-
if cancellable != nil {
82-
cont.finish()
83-
cancellable = nil
84-
}
85-
}
86-
}
87-
88-
let _cancellable = self.messagesPublisher()
89-
.handleEvents(receiveCancel: { finish() })
90-
.sink(
91-
receiveCompletion: { _ in finish() },
92-
receiveValue: { cont.yield($0) }
93-
)
94-
95-
cancellable.access { $0 = _cancellable }
96-
}
76+
let (stream, continuation) = AsyncStream<WebSocketMessage>.makeStream()
77+
let cancellable = messagesPublisher()
78+
.handleEvents(receiveCancel: { continuation.finish() })
79+
.sink(
80+
receiveCompletion: { _ in continuation.finish() },
81+
receiveValue: { continuation.yield($0) }
82+
)
83+
continuation.onTermination = { _ in cancellable.cancel() }
84+
return stream
9785
}
9886
}
9987

Tests/WebSocketTests/Server/WebSocketServer.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import Combine
22
import Foundation
33
import NIO
4+
import NIOWebSocket
45
import WebSocket
56
import WebSocketKit
67

7-
enum WebSocketServerOutput: Hashable {
8+
enum WebSocketServerOutput {
89
case message(WebSocketMessage)
910
case remoteClose
11+
case remoteCloseWithReason(WebSocketErrorCode, Data)
1012
}
1113

1214
final class WebSocketServer {
@@ -71,6 +73,15 @@ final class WebSocketServer {
7173
do { try ws.close(code: .goingAway).wait() }
7274
catch {}
7375

76+
case let .remoteCloseWithReason(code, reason):
77+
var buffer = ByteBufferAllocator().buffer(capacity: 2 + reason.count)
78+
buffer.write(webSocketErrorCode: code)
79+
buffer.writeBytes(reason)
80+
ws.send(
81+
raw: buffer.readableBytesView,
82+
opcode: .connectionClose
83+
)
84+
7485
case let .message(message):
7586
switch message {
7687
case let .data(data):

0 commit comments

Comments
 (0)