Skip to content

Commit 5cf4f56

Browse files
authored
fix(realtime): crash when connecting socket (#470)
* fix(realtime): crash when connecting socket * fix test * Fix slack clone example * import FoundationNetworking
1 parent b8f8164 commit 5cf4f56

File tree

11 files changed

+118
-158
lines changed

11 files changed

+118
-158
lines changed

Examples/Examples.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,7 @@
768768
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
769769
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
770770
MARKETING_VERSION = 1.0;
771-
PRODUCT_BUNDLE_IDENTIFIER = com.supabase.SlackClone;
771+
PRODUCT_BUNDLE_IDENTIFIER = "com.supabase.slack-clone";
772772
PRODUCT_NAME = "$(TARGET_NAME)";
773773
SDKROOT = auto;
774774
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
@@ -809,7 +809,7 @@
809809
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
810810
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
811811
MARKETING_VERSION = 1.0;
812-
PRODUCT_BUNDLE_IDENTIFIER = com.supabase.SlackClone;
812+
PRODUCT_BUNDLE_IDENTIFIER = "com.supabase.slack-clone";
813813
PRODUCT_NAME = "$(TARGET_NAME)";
814814
SDKROOT = auto;
815815
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";

Examples/SlackClone/AuthView.swift

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,27 +13,20 @@ final class AuthViewModel {
1313
var email = ""
1414
var toast: ToastState?
1515

16-
func signInButtonTapped() {
17-
Task {
18-
do {
19-
try await supabase.auth.signInWithOTP(
20-
email: email,
21-
redirectTo: URL(string: "slackclone://sign-in")
22-
)
23-
toast = ToastState(status: .success, title: "Check your inbox.")
24-
} catch {
25-
toast = ToastState(status: .error, title: "Error", description: error.localizedDescription)
26-
}
27-
}
28-
}
16+
func signInButtonTapped() async {
17+
do {
18+
try await supabase.auth.signInWithOTP(email: email)
19+
toast = ToastState(status: .success, title: "Check your inbox.")
2920

30-
func handle(_ url: URL) {
31-
Task {
32-
do {
33-
try await supabase.auth.session(from: url)
34-
} catch {
35-
toast = ToastState(status: .error, title: "Error", description: error.localizedDescription)
36-
}
21+
try? await Task.sleep(for: .seconds(1))
22+
23+
#if os(macOS)
24+
NSWorkspace.shared.open(URL(string: "http://127.0.0.1:54324")!)
25+
#else
26+
await UIApplication.shared.open(URL(string: "http://127.0.0.1:54324")!)
27+
#endif
28+
} catch {
29+
toast = ToastState(status: .error, title: "Error", description: error.localizedDescription)
3730
}
3831
}
3932
}
@@ -54,12 +47,11 @@ struct AuthView: View {
5447
.autocorrectionDisabled()
5548
}
5649
Button("Sign in with Magic Link") {
57-
model.signInButtonTapped()
50+
Task { await model.signInButtonTapped() }
5851
}
5952
}
6053
.padding()
6154
.toast(state: $model.toast)
62-
.onOpenURL { model.handle($0) }
6355
}
6456
}
6557

Examples/SlackClone/Info.plist

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,12 @@
1010
<key>CFBundleURLIconFile</key>
1111
<string></string>
1212
<key>CFBundleURLName</key>
13-
<string>com.supabase.SlackClone</string>
13+
<string>com.supabase.slack-clone</string>
1414
<key>CFBundleURLSchemes</key>
1515
<array>
16-
<string>slackclone</string>
16+
<string>com.supabase.slack-clone</string>
1717
</array>
1818
</dict>
19-
<dict/>
2019
</array>
2120
</dict>
2221
</plist>

Examples/SlackClone/SlackCloneApp.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ struct SlackCloneApp: App {
1515
var body: some Scene {
1616
WindowGroup {
1717
AppView(model: model)
18+
.onOpenURL { url in
19+
supabase.handle(url)
20+
}
1821
}
1922
}
2023
}

Examples/SlackClone/Supabase.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ let supabase = SupabaseClient(
2525
supabaseKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0",
2626
options: SupabaseClientOptions(
2727
db: .init(encoder: encoder, decoder: decoder),
28+
auth: .init(redirectToURL: URL(string: "com.supabase.slack-clone://")),
2829
global: SupabaseClientOptions.GlobalOptions(logger: LogStore.shared)
2930
)
3031
)

Examples/SlackClone/supabase/config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ enabled = true
7171
# in emails.
7272
site_url = "http://127.0.0.1:3000"
7373
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
74-
additional_redirect_urls = ["https://127.0.0.1:3000", "slackclone://*"]
74+
additional_redirect_urls = ["https://127.0.0.1:3000", "com.supabase.slack-clone://"]
7575
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
7676
jwt_expiry = 3600
7777
# If disabled, the refresh token will never expire.

Sources/Realtime/V2/WebSocketClient.swift

Lines changed: 14 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,7 @@ protocol WebSocketClient: Sendable {
2323
func send(_ message: RealtimeMessageV2) async throws
2424
func receive() -> AsyncThrowingStream<RealtimeMessageV2, any Error>
2525
func connect() -> AsyncStream<ConnectionStatus>
26-
func disconnect(closeCode: URLSessionWebSocketTask.CloseCode)
27-
}
28-
29-
extension WebSocketClient {
30-
func disconnect() {
31-
disconnect(closeCode: .normalClosure)
32-
}
26+
func disconnect()
3327
}
3428

3529
final class WebSocket: NSObject, URLSessionWebSocketDelegate, WebSocketClient, @unchecked Sendable {
@@ -39,7 +33,7 @@ final class WebSocket: NSObject, URLSessionWebSocketDelegate, WebSocketClient, @
3933

4034
struct MutableState {
4135
var continuation: AsyncStream<ConnectionStatus>.Continuation?
42-
var stream: SocketStream?
36+
var connection: WebSocketConnection<RealtimeMessageV2, RealtimeMessageV2>?
4337
}
4438

4539
private let mutableState = LockIsolated(MutableState())
@@ -57,7 +51,7 @@ final class WebSocket: NSObject, URLSessionWebSocketDelegate, WebSocketClient, @
5751
mutableState.withValue { state in
5852
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
5953
let task = session.webSocketTask(with: realtimeURL)
60-
state.stream = SocketStream(task: task)
54+
state.connection = WebSocketConnection(task: task)
6155
task.resume()
6256

6357
let (stream, continuation) = AsyncStream<ConnectionStatus>.makeStream()
@@ -66,51 +60,27 @@ final class WebSocket: NSObject, URLSessionWebSocketDelegate, WebSocketClient, @
6660
}
6761
}
6862

69-
func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) {
63+
func disconnect() {
7064
mutableState.withValue { state in
71-
state.stream?.cancel(with: closeCode)
65+
state.connection?.close()
7266
}
7367
}
7468

7569
func receive() -> AsyncThrowingStream<RealtimeMessageV2, any Error> {
76-
mutableState.withValue { mutableState in
77-
guard let stream = mutableState.stream else {
78-
return .finished(
79-
throwing: RealtimeError(
80-
"receive() called before connect(). Make sure to call `connect()` before calling `receive()`."
81-
)
70+
guard let connection = mutableState.connection else {
71+
return .finished(
72+
throwing: RealtimeError(
73+
"receive() called before connect(). Make sure to call `connect()` before calling `receive()`."
8274
)
83-
}
84-
85-
return stream.map { message in
86-
switch message {
87-
case let .string(stringMessage):
88-
self.logger?.verbose("Received message: \(stringMessage)")
89-
90-
guard let data = stringMessage.data(using: .utf8) else {
91-
throw RealtimeError("Expected a UTF8 encoded message.")
92-
}
93-
94-
let message = try JSONDecoder().decode(RealtimeMessageV2.self, from: data)
95-
return message
96-
97-
case .data:
98-
fallthrough
99-
100-
default:
101-
throw RealtimeError("Unsupported message type.")
102-
}
103-
}
104-
.eraseToThrowingStream()
75+
)
10576
}
77+
78+
return connection.receive()
10679
}
10780

10881
func send(_ message: RealtimeMessageV2) async throws {
109-
let data = try JSONEncoder().encode(message)
110-
let string = String(decoding: data, as: UTF8.self)
111-
112-
logger?.verbose("Sending message: \(string)")
113-
try await mutableState.stream?.send(.string(string))
82+
logger?.verbose("Sending message: \(message)")
83+
try await mutableState.connection?.send(message)
11484
}
11585

11686
// MARK: - URLSessionWebSocketDelegate
@@ -145,85 +115,3 @@ final class WebSocket: NSObject, URLSessionWebSocketDelegate, WebSocketClient, @
145115
mutableState.continuation?.yield(.error(error))
146116
}
147117
}
148-
149-
typealias WebSocketStream = AsyncThrowingStream<URLSessionWebSocketTask.Message, any Error>
150-
151-
final class SocketStream: AsyncSequence, Sendable {
152-
typealias AsyncIterator = WebSocketStream.Iterator
153-
typealias Element = URLSessionWebSocketTask.Message
154-
155-
struct MutableState {
156-
var continuation: WebSocketStream.Continuation?
157-
var stream: WebSocketStream?
158-
}
159-
160-
private let task: URLSessionWebSocketTask
161-
private let mutableState = LockIsolated(MutableState())
162-
163-
private func makeStreamIfNeeded() -> WebSocketStream {
164-
mutableState.withValue { state in
165-
if let stream = state.stream {
166-
return stream
167-
}
168-
169-
let stream = WebSocketStream { continuation in
170-
state.continuation = continuation
171-
waitForNextValue()
172-
}
173-
174-
state.stream = stream
175-
return stream
176-
}
177-
}
178-
179-
private func waitForNextValue() {
180-
guard task.closeCode == .invalid else {
181-
mutableState.continuation?.finish()
182-
return
183-
}
184-
185-
task.receive { [weak self] result in
186-
guard let continuation = self?.mutableState.continuation else { return }
187-
188-
do {
189-
let message = try result.get()
190-
continuation.yield(message)
191-
self?.waitForNextValue()
192-
} catch {
193-
continuation.finish(throwing: error)
194-
}
195-
}
196-
}
197-
198-
init(task: URLSessionWebSocketTask) {
199-
self.task = task
200-
}
201-
202-
deinit {
203-
mutableState.continuation?.finish()
204-
}
205-
206-
func makeAsyncIterator() -> WebSocketStream.Iterator {
207-
makeStreamIfNeeded().makeAsyncIterator()
208-
}
209-
210-
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode = .goingAway) {
211-
task.cancel(with: closeCode, reason: nil)
212-
mutableState.continuation?.finish()
213-
}
214-
215-
func send(_ message: URLSessionWebSocketTask.Message) async throws {
216-
try await task.send(message)
217-
}
218-
}
219-
220-
#if os(Linux) || os(Windows)
221-
extension URLSessionWebSocketTask {
222-
func receive(completionHandler: @Sendable @escaping (Result<URLSessionWebSocketTask.Message, any Error>) -> Void) {
223-
Task {
224-
let result = await Result(catching: { try await self.receive() })
225-
completionHandler(result)
226-
}
227-
}
228-
}
229-
#endif
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//
2+
// WebSocketConnection.swift
3+
//
4+
//
5+
// Created by Guilherme Souza on 29/03/24.
6+
//
7+
8+
import Foundation
9+
10+
#if canImport(FoundationNetworking)
11+
import FoundationNetworking
12+
#endif
13+
14+
enum WebSocketConnectionError: Error {
15+
case unsupportedData
16+
}
17+
18+
final class WebSocketConnection<Incoming: Codable, Outgoing: Codable>: Sendable {
19+
private let task: URLSessionWebSocketTask
20+
private let encoder: JSONEncoder
21+
private let decoder: JSONDecoder
22+
23+
init(
24+
task: URLSessionWebSocketTask,
25+
encoder: JSONEncoder = JSONEncoder(),
26+
decoder: JSONDecoder = JSONDecoder()
27+
) {
28+
self.task = task
29+
self.encoder = encoder
30+
self.decoder = decoder
31+
32+
task.resume()
33+
}
34+
35+
deinit {
36+
task.cancel(with: .goingAway, reason: nil)
37+
}
38+
39+
func receiveOnce() async throws -> Incoming {
40+
switch try await task.receive() {
41+
case let .data(data):
42+
let message = try decoder.decode(Incoming.self, from: data)
43+
return message
44+
45+
case let .string(string):
46+
guard let data = string.data(using: .utf8) else {
47+
throw WebSocketConnectionError.unsupportedData
48+
}
49+
50+
let message = try decoder.decode(Incoming.self, from: data)
51+
return message
52+
53+
@unknown default:
54+
assertionFailure("Unsupported message type.")
55+
task.cancel(with: .unsupportedData, reason: nil)
56+
throw WebSocketConnectionError.unsupportedData
57+
}
58+
}
59+
60+
func send(_ message: Outgoing) async throws {
61+
let data = try encoder.encode(message)
62+
try await task.send(.data(data))
63+
}
64+
65+
func receive() -> AsyncThrowingStream<Incoming, any Error> {
66+
AsyncThrowingStream { [weak self] in
67+
guard let self else { return nil }
68+
69+
let message = try await receiveOnce()
70+
return Task.isCancelled ? nil : message
71+
}
72+
}
73+
74+
func close() {
75+
task.cancel(with: .normalClosure, reason: nil)
76+
}
77+
}

Sources/TestHelpers/HTTPClientMock.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ package actor HTTPClientMock: HTTPClientType {
1414
package struct MockNotFound: Error {}
1515

1616
private var mocks = [@Sendable (HTTPRequest) async throws -> HTTPResponse?]()
17-
17+
1818
/// Requests received by this client in order.
1919
package var receivedRequests: [HTTPRequest] = []
2020

@@ -47,7 +47,7 @@ package actor HTTPClientMock: HTTPClientType {
4747
package func send(_ request: HTTPRequest) async throws -> HTTPResponse {
4848
receivedRequests.append(request)
4949

50-
for mock in mocks{
50+
for mock in mocks {
5151
do {
5252
if let response = try await mock(request) {
5353
returnedResponses.append(.success(response))

0 commit comments

Comments
 (0)