-
Notifications
You must be signed in to change notification settings - Fork 228
Add WsClient and HttpClient implementations for livekit-net. #1073
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c721ea2
2ac25f2
14a12b6
13b66c1
2b6abc3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| // Copyright 2026 LiveKit (Apache-2.0) | ||
| import Foundation | ||
| internal import LiveKitUniFFI | ||
|
|
||
| /// Live HTTP transport backing `livekit-net`, over `URLSession`. Byte-faithful: | ||
| /// returns raw response bytes; never decodes to string/JSON. | ||
| final class LKNetHTTPClientLive: HttpClient, @unchecked Sendable { | ||
|
Check failure on line 7 in Sources/LiveKit/Net/LKNetHTTPClient.swift
|
||
| private let session: URLSession | ||
|
|
||
| init(session: URLSession = URLSession(configuration: .default)) { | ||
| self.session = session | ||
| } | ||
|
|
||
| func request(method: HttpMethod, url: String, headers: [Header], body: Data?) async throws -> HttpResponse { | ||
|
Check failure on line 14 in Sources/LiveKit/Net/LKNetHTTPClient.swift
|
||
| guard let u = URL(string: url) else { throw TransportError.Connection("invalid url: \(url)") } | ||
| var req = URLRequest(url: u) | ||
| req.httpMethod = method == .get ? "GET" : "POST" | ||
| for h in headers { req.addValue(h.value, forHTTPHeaderField: h.name) } | ||
| if let body { req.httpBody = body } | ||
|
|
||
| do { | ||
| let (data, response): (Data, URLResponse) = try await withCheckedThrowingContinuation { cont in | ||
| let task = session.dataTask(with: req) { data, response, error in | ||
| if let error { cont.resume(throwing: error) } | ||
| else if let data, let response { cont.resume(returning: (data, response)) } | ||
| else { cont.resume(throwing: URLError(.badServerResponse)) } | ||
| } | ||
| task.resume() | ||
| } | ||
| guard let http = response as? HTTPURLResponse else { | ||
| throw TransportError.Other("non-HTTP response") | ||
| } | ||
| let respHeaders: [Header] = http.allHeaderFields.compactMap { key, value in | ||
| guard let name = key as? String else { return nil } | ||
| return Header(name: name, value: "\(value)") | ||
| } | ||
| return HttpResponse(status: UInt16(clamping: http.statusCode), headers: respHeaders, body: data) | ||
| } catch let t as TransportError { | ||
| throw t | ||
| } catch let e as URLError { | ||
| switch e.code { | ||
| case .timedOut: throw TransportError.Timeout | ||
| case .cancelled: throw TransportError.Closed | ||
| default: throw TransportError.Connection(e.localizedDescription) | ||
| } | ||
| } catch { | ||
| throw TransportError.Connection("\(error)") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Forwarding HTTP client registered with `livekit-net`. Production forwards to | ||
| /// `LKNetHTTPClientLive`; `#if DEBUG` tests swap the delegate to a dummy. | ||
| final class LKNetHTTPClient: HttpClient, @unchecked Sendable { | ||
|
Check failure on line 54 in Sources/LiveKit/Net/LKNetHTTPClient.swift
|
||
| private let delegate: StateSync<any HttpClient> | ||
|
Check failure on line 55 in Sources/LiveKit/Net/LKNetHTTPClient.swift
|
||
|
|
||
| init(_ initial: any HttpClient = LKNetHTTPClientLive()) { | ||
|
Check failure on line 57 in Sources/LiveKit/Net/LKNetHTTPClient.swift
|
||
| delegate = StateSync(initial) | ||
| } | ||
|
|
||
| #if DEBUG | ||
| func setDelegate(_ d: any HttpClient) { delegate.mutate { $0 = d } } | ||
|
Check failure on line 62 in Sources/LiveKit/Net/LKNetHTTPClient.swift
|
||
| #endif | ||
|
|
||
| func request(method: HttpMethod, url: String, headers: [Header], body: Data?) async throws -> HttpResponse { | ||
|
Check failure on line 65 in Sources/LiveKit/Net/LKNetHTTPClient.swift
|
||
| try await delegate.read { $0 }.request(method: method, url: url, headers: headers, body: body) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| // Copyright 2026 LiveKit (Apache-2.0) | ||
| import Foundation | ||
| internal import LiveKitUniFFI | ||
|
|
||
| /// Live WebSocket transport backing `livekit-net`, over `URLSessionWebSocketTask`. | ||
| final class LKNetWSClientLive: WsClient, @unchecked Sendable { | ||
| func connect(url: String, headers: [Header], timeoutMs: UInt64) async throws -> WsConnectResult { | ||
| guard let u = URL(string: url) else { throw TransportError.Connection("invalid url: \(url)") } | ||
| let conn = try await LKNetWSConnection(url: u, headers: headers, timeoutMs: timeoutMs) | ||
| return WsConnectResult(connection: conn) | ||
| } | ||
| } | ||
|
|
||
| /// Forwarding WS client registered with `livekit-net`. Production forwards to | ||
| /// `LKNetWSClientLive`; `#if DEBUG` tests swap the delegate to a dummy. | ||
| final class LKNetWSClient: WsClient, @unchecked Sendable { | ||
| private let delegate: StateSync<any WsClient> | ||
|
|
||
| init(_ initial: any WsClient = LKNetWSClientLive()) { | ||
| delegate = StateSync(initial) | ||
| } | ||
|
|
||
| #if DEBUG | ||
| func setDelegate(_ d: any WsClient) { delegate.mutate { $0 = d } } | ||
| #endif | ||
|
|
||
| func connect(url: String, headers: [Header], timeoutMs: UInt64) async throws -> WsConnectResult { | ||
| try await delegate.read { $0 }.connect(url: url, headers: headers, timeoutMs: timeoutMs) | ||
| } | ||
| } | ||
|
|
||
| /// One open WebSocket for `livekit-net`. Binary frames only; string frames are | ||
| /// decoded as UTF-8 bytes at the boundary (Swift does not interpret content). | ||
| final class LKNetWSConnection: WsConnection, @unchecked Sendable { | ||
| private let session: URLSession | ||
| private let task: URLSessionWebSocketTask | ||
| private let connectDelegate: WSConnectDelegate | ||
|
|
||
| init(url: URL, headers: [Header], timeoutMs: UInt64) async throws { | ||
| var req = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, | ||
| timeoutInterval: TimeInterval(timeoutMs) / 1000.0) | ||
| for h in headers { req.addValue(h.value, forHTTPHeaderField: h.name) } | ||
|
|
||
| connectDelegate = WSConnectDelegate() | ||
| session = URLSession(configuration: .default, delegate: connectDelegate, delegateQueue: nil) | ||
| task = session.webSocketTask(with: req) | ||
|
|
||
| do { | ||
| try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in | ||
| connectDelegate.setConnectContinuation(cont) | ||
| task.resume() | ||
| } | ||
| } catch { | ||
| session.invalidateAndCancel() | ||
| throw Self.map(error) | ||
| } | ||
| } | ||
|
|
||
| deinit { | ||
| task.cancel(with: .normalClosure, reason: nil) | ||
| session.invalidateAndCancel() | ||
| } | ||
|
|
||
| func send(frame: Data) async throws { | ||
| do { | ||
| try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in | ||
| task.send(.data(frame)) { error in | ||
| if let error { cont.resume(throwing: error) } else { cont.resume() } | ||
| } | ||
| } | ||
| } catch { | ||
| throw Self.map(error) | ||
| } | ||
| } | ||
|
|
||
| func recv() async throws -> Data? { | ||
| guard task.closeCode == .invalid else { return nil } | ||
| do { | ||
| let message: URLSessionWebSocketTask.Message = try await withCheckedThrowingContinuation { cont in | ||
| task.receive { cont.resume(with: $0) } | ||
| } | ||
| return Self.decode(message) | ||
| } catch { | ||
| if task.closeCode != .invalid { return nil } | ||
| throw Self.map(error) | ||
| } | ||
| } | ||
|
|
||
| func close() async { | ||
| task.cancel(with: .normalClosure, reason: nil) | ||
| session.finishTasksAndInvalidate() | ||
| } | ||
|
|
||
| static func decode(_ message: URLSessionWebSocketTask.Message) -> Data { | ||
| switch message { | ||
| case let .data(d): return d | ||
| case let .string(s): return Data(s.utf8) | ||
| @unknown default: return Data() | ||
| } | ||
| } | ||
|
|
||
| private static func map(_ error: Error) -> TransportError { | ||
| if let u = error as? URLError { | ||
| switch u.code { | ||
| case .timedOut: return .Timeout | ||
| case .cancelled: return .Closed | ||
| default: return .Connection(u.localizedDescription) | ||
| } | ||
| } | ||
| return .Connection("\(error)") | ||
| } | ||
| } | ||
|
|
||
| /// Bridges `URLSessionWebSocketDelegate` open/close callbacks to a continuation. | ||
| /// Mirrors `Support/Network/WebSocket.swift`'s `Delegate`. | ||
| private final class WSConnectDelegate: NSObject, URLSessionWebSocketDelegate { | ||
| private let continuation = StateSync<CheckedContinuation<Void, Error>?>(nil) | ||
|
|
||
| func setConnectContinuation(_ c: CheckedContinuation<Void, Error>) { | ||
| continuation.mutate { $0 = c } | ||
| } | ||
|
|
||
| func urlSession(_: URLSession, webSocketTask _: URLSessionWebSocketTask, didOpenWithProtocol _: String?) { | ||
| continuation.mutate { $0?.resume(); $0 = nil } | ||
| } | ||
|
|
||
| func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: Error?) { | ||
| continuation.mutate { | ||
| if let error { $0?.resume(throwing: error) } else { $0?.resume() } | ||
| $0 = nil | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,23 @@ | ||||||||||||||||||||||||||||||||||||
| // Copyright 2026 LiveKit (Apache-2.0) | ||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 New source and test files are missing the required license header The newly added files start with a one-line copyright comment instead of the mandated Apache header block ( Header enforced by .swiftformat
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||||||||||||||||||||||||
| internal import LiveKitUniFFI | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /// One-time registration of the SDK's transports with `livekit-net`. | ||||||||||||||||||||||||||||||||||||
| /// `setHttpClient`/`setWsClient` are first-registration-wins; a Swift `static let` | ||||||||||||||||||||||||||||||||||||
| /// guarantees this runs exactly once, thread-safe, before the first connection. | ||||||||||||||||||||||||||||||||||||
| enum LKNet { | ||||||||||||||||||||||||||||||||||||
| #if DEBUG | ||||||||||||||||||||||||||||||||||||
| // Swappable forwarding wrappers so FFI-demo tests can inject dummies. | ||||||||||||||||||||||||||||||||||||
| static let httpClient = LKNetHTTPClient() | ||||||||||||||||||||||||||||||||||||
| static let wsClient = LKNetWSClient() | ||||||||||||||||||||||||||||||||||||
| #endif | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| static let bootstrap: Void = { | ||||||||||||||||||||||||||||||||||||
| #if DEBUG | ||||||||||||||||||||||||||||||||||||
| setHttpClient(c: httpClient) | ||||||||||||||||||||||||||||||||||||
| setWsClient(c: wsClient) | ||||||||||||||||||||||||||||||||||||
| #else | ||||||||||||||||||||||||||||||||||||
| setHttpClient(c: LKNetHTTPClientLive()) | ||||||||||||||||||||||||||||||||||||
| setWsClient(c: LKNetWSClientLive()) | ||||||||||||||||||||||||||||||||||||
| #endif | ||||||||||||||||||||||||||||||||||||
| }() | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| // Copyright 2026 LiveKit (Apache-2.0) | ||
| import Foundation | ||
| import Testing | ||
| @testable import LiveKit | ||
| import LiveKitUniFFI | ||
|
|
||
| // Uses a dedicated, file-private URLProtocol (not the shared LiveKitTestSupport | ||
| // `MockURLProtocol`) so this suite's process-global mock state cannot be reset by | ||
| // another suite that also uses `MockURLProtocol` (e.g. `RegionManagerTests`) when | ||
| // Swift Testing runs suites in parallel. `.serialized` keeps this suite's own tests | ||
| // from racing each other on that state. | ||
| @Suite(.serialized) | ||
| struct LKNetHTTPClientLiveTests { | ||
| private func mockSession() -> URLSession { | ||
| let config = URLSessionConfiguration.ephemeral | ||
| config.protocolClasses = [LKNetMockURLProtocol.self] | ||
| return URLSession(configuration: config) | ||
| } | ||
|
|
||
| @Test func getMapsResponse() async throws { | ||
| LKNetMockURLProtocol.reset() | ||
| defer { LKNetMockURLProtocol.reset() } | ||
| LKNetMockURLProtocol.setRequestHandler { req in | ||
| #expect(req.httpMethod == "GET") | ||
| #expect(req.value(forHTTPHeaderField: "x-req") == "1") | ||
| return .init(statusCode: 201, headers: ["x-test": "yes"], body: Data("hi".utf8)) | ||
| } | ||
| let client = LKNetHTTPClientLive(session: mockSession()) | ||
| let resp = try await client.request(method: .get, url: "https://example.test/status", | ||
| headers: [Header(name: "x-req", value: "1")], body: nil) | ||
| #expect(resp.status == 201) | ||
| #expect(resp.body == Data("hi".utf8)) | ||
| #expect(resp.headers.contains { $0.name.lowercased() == "x-test" && $0.value == "yes" }) | ||
| } | ||
|
|
||
| @Test func timeoutMapsToTransportTimeout() async throws { | ||
| LKNetMockURLProtocol.reset() | ||
| defer { LKNetMockURLProtocol.reset() } | ||
| LKNetMockURLProtocol.setRequestHandler { _ in throw URLError(.timedOut) } | ||
| let client = LKNetHTTPClientLive(session: mockSession()) | ||
| await #expect(throws: TransportError.self) { | ||
| _ = try await client.request(method: .get, url: "https://example.test/slow", headers: [], body: nil) | ||
| } | ||
| } | ||
|
|
||
| @Test func forwardingDelegatesToInner() async throws { | ||
| LKNetMockURLProtocol.reset() | ||
| defer { LKNetMockURLProtocol.reset() } | ||
| LKNetMockURLProtocol.setRequestHandler { _ in .init(statusCode: 200, headers: [:], body: Data()) } | ||
| let fwd = LKNetHTTPClient(LKNetHTTPClientLive(session: mockSession())) | ||
| let resp = try await fwd.request(method: .get, url: "https://example.test/f", headers: [], body: nil) | ||
| #expect(resp.status == 200) | ||
| } | ||
| } | ||
|
|
||
| /// Isolated `URLProtocol` for `LKNetHTTPClientLiveTests` only. Its state is process | ||
| /// global (as all `URLProtocol` state must be), but no other suite references this | ||
| /// class, so it cannot be reset mid-request by a parallel suite. Attached solely via | ||
| /// the ephemeral session's `protocolClasses`, so it never intercepts other traffic. | ||
| private final class LKNetMockURLProtocol: URLProtocol { | ||
| struct Response: Sendable { | ||
| let statusCode: Int | ||
| let headers: [String: String] | ||
| let body: Data | ||
| } | ||
|
|
||
| private struct State { | ||
| var requestHandler: (@Sendable (URLRequest) throws -> Response)? | ||
| } | ||
|
|
||
| private static let _state = StateSync(State()) | ||
|
|
||
| static func setRequestHandler(_ handler: (@Sendable (URLRequest) throws -> Response)?) { | ||
| _state.mutate { $0.requestHandler = handler } | ||
| } | ||
|
|
||
| static func reset() { | ||
| _state.mutate { $0.requestHandler = nil } | ||
| } | ||
|
|
||
| override class func canInit(with _: URLRequest) -> Bool { true } | ||
|
|
||
| override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } | ||
|
|
||
| override func startLoading() { | ||
| guard let handler = Self._state.read({ $0.requestHandler }) else { | ||
| client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) | ||
| return | ||
| } | ||
| guard let url = request.url else { | ||
| client?.urlProtocol(self, didFailWithError: URLError(.badURL)) | ||
| return | ||
| } | ||
| do { | ||
| let mock = try handler(request) | ||
| guard let response = HTTPURLResponse(url: url, | ||
| statusCode: mock.statusCode, | ||
| httpVersion: "HTTP/1.1", | ||
| headerFields: mock.headers) | ||
| else { | ||
| client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) | ||
| return | ||
| } | ||
| client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) | ||
| client?.urlProtocol(self, didLoad: mock.body) | ||
| client?.urlProtocolDidFinishLoading(self) | ||
| } catch { | ||
| client?.urlProtocol(self, didFailWithError: error) | ||
| } | ||
| } | ||
|
|
||
| override func stopLoading() {} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| // Copyright 2026 LiveKit (Apache-2.0) | ||
| import Foundation | ||
| import Testing | ||
| @testable import LiveKit | ||
|
|
||
| struct LKNetWSConnectionTests { | ||
| @Test func decodeDataFrame() { | ||
| let d = Data([0x01, 0x02, 0x03]) | ||
| #expect(LKNetWSConnection.decode(.data(d)) == d) | ||
| } | ||
|
|
||
| @Test func decodeStringFrameIsUTF8() { | ||
| #expect(LKNetWSConnection.decode(.string("hi")) == Data("hi".utf8)) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| // Copyright 2026 LiveKit (Apache-2.0) | ||
| import Testing | ||
| @testable import LiveKit | ||
| import LiveKitUniFFI | ||
|
Check warning on line 4 in Tests/LiveKitCoreTests/Net/NetBootstrapTests.swift
|
||
|
|
||
| struct NetBootstrapTests { | ||
| @Test func bootstrapRegistersTransports() { | ||
| _ = LKNet.bootstrap | ||
| #expect(hasHttpClient()) | ||
| #expect(hasWsClient()) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Cancelled websocket operations never finish, keeping the socket and its session alive
The websocket receive and connect steps wait on a callback with no cancellation path (
withCheckedThrowingContinuationatSources/LiveKit/Net/LKNetWSClient.swift:79-81), so when the caller cancels, the wait never ends on an idle socket and the connection is held open forever.Impact: Cancelled network work can hang indefinitely and leak the underlying socket and session for the lifetime of the app.
Missing cooperative cancellation compared to the existing WebSocket implementation
Sources/LiveKit/Support/Network/WebSocket.swift:57-64wraps the connect continuation inwithTaskCancellationHandlerand also hasDelegate.cancelConnection()(Sources/LiveKit/Support/Network/WebSocket.swift:71-74,122-127) so a cancelled connect resumes the continuation;WebSocket.AsyncIterator.next()(Sources/LiveKit/Support/Network/WebSocket.swift:83-101) wrapstask.receiveinwithTaskCancellationHandlerand returnsnilwhenTask.isCancelled.The new
LKNetWSConnection.init(Sources/LiveKit/Net/LKNetWSClient.swift:48-56) andrecv()have neither: on cancellation the continuation stays suspended until the underlying URLSession callback eventually fires. Forrecv()on an idle, healthy socket no callback ever fires, so the suspended async frame keepsself(and thus theURLSessionWebSocketTaskand theURLSessioncreated with a delegate) alive indefinitely, anddeinit's cleanup never runs. The same lack of cancellation applies toLKNetHTTPClientLive.request(Sources/LiveKit/Net/LKNetHTTPClient.swift:22-29), where the data task is not cancelled when the awaiting task is cancelled.AGENTS.md: "Long-running
Taskrequires cooperative cancellation to avoid memory leaks".Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.