From 7e0b1aa2e63f451920f5a1fc0246df5c85f81ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:33:52 +0200 Subject: [PATCH 1/4] Register the SDK's own transport with livekit-net livekit-net leaves the WebSocket and HTTP clients to the host. Rather than add a second, parallel URLSession stack for the Rust side, adapt the one already here, so Rust-side signalling inherits the tuning the Swift path gets: .callSignaling QoS, multipath handover, extended background idle, the iOS 26 simulator TLS workaround, and the delegate's error logging. Registration is a lazy global, forced from ConnectionDependencies.init: process-wide rather than connection-scoped, since livekit-net keeps the first registration, but that is the earliest construction on the connect path, so no signalling can outrun it and a Room that never connects pays nothing. WebSocket gains a headers/timeout init (SignalClient supplies its own Authorization header) and keeps the HTTP status off a rejected upgrade, which is what livekit-net's TransportError::Http carries: livekit-signaling maps it to SignalError::Handshake, where a 404 gates the v1 -> v0 signalling path fallback and other statuses fail fast rather than driving a reconnect loop. HTTP.request now owns the cache policy and timeout as parameters, so the shared session's URLCache can't serve a stale validate or region response to either caller. The tests drive the seam from Rust through the FFI using livekit-net's own self_test_* exports, so nothing is asserted against a mock. Co-Authored-By: Claude Opus 5 (1M context) --- .changes/rust-net-transport | 1 + Sources/LiveKit/Core/RoomDependencies.swift | 5 + Sources/LiveKit/Core/SignalClient.swift | 4 +- Sources/LiveKit/Support/Network/HTTP.swift | 35 ++- .../Support/Network/RustTransport.swift | 182 ++++++++++++ .../LiveKit/Support/Network/WebSocket.swift | 30 +- .../LiveKitCoreTests/RustTransportTests.swift | 263 ++++++++++++++++++ Tests/LiveKitTestSupport/Room.swift | 2 +- .../LiveKitTestSupport/TestEnvironment.swift | 10 +- 9 files changed, 508 insertions(+), 24 deletions(-) create mode 100644 .changes/rust-net-transport create mode 100644 Sources/LiveKit/Support/Network/RustTransport.swift create mode 100644 Tests/LiveKitCoreTests/RustTransportTests.swift diff --git a/.changes/rust-net-transport b/.changes/rust-net-transport new file mode 100644 index 000000000..9ba225592 --- /dev/null +++ b/.changes/rust-net-transport @@ -0,0 +1 @@ +patch type="changed" "Register the SDK's own WebSocket/URLSession stack as livekit-net's transport, so Rust-side signalling and HTTP reuse the tuned session (call-signaling QoS, multipath handover, TLS workarounds) instead of a second parallel client; a rejected WebSocket upgrade now keeps its HTTP status" diff --git a/Sources/LiveKit/Core/RoomDependencies.swift b/Sources/LiveKit/Core/RoomDependencies.swift index 061846894..6514d9b4e 100644 --- a/Sources/LiveKit/Core/RoomDependencies.swift +++ b/Sources/LiveKit/Core/RoomDependencies.swift @@ -37,6 +37,11 @@ final class ConnectionDependencies: Sendable { let e2ee: StateSync init(room: Room, roomOptions: RoomOptions) { + // Process-wide rather than connection-scoped — livekit-net keeps the first + // registration — but this is the earliest construction on the connect path, so + // no signalling can outrun it. + _ = rustTransport + dataTracks = DataTracks(room: room) let manager: E2EEManager? = if let e2eeOptions = roomOptions.e2eeOptions { E2EEManager(e2eeOptions: e2eeOptions) diff --git a/Sources/LiveKit/Core/SignalClient.swift b/Sources/LiveKit/Core/SignalClient.swift index aae6d08f1..415b03ef7 100644 --- a/Sources/LiveKit/Core/SignalClient.swift +++ b/Sources/LiveKit/Core/SignalClient.swift @@ -189,8 +189,8 @@ actor SignalClient: Loggable { do { let socket = try await WebSocket(url: url, - token: token, - connectOptions: connectOptions) + headers: ["Authorization": "Bearer \(token)"], + timeoutInterval: connectOptions?.socketConnectTimeoutInterval ?? .defaultSocketConnect) connectSpan?.record("ws_open") startDataTrackResponses() diff --git a/Sources/LiveKit/Support/Network/HTTP.swift b/Sources/LiveKit/Support/Network/HTTP.swift index fd854e67d..43911bca6 100644 --- a/Sources/LiveKit/Support/Network/HTTP.swift +++ b/Sources/LiveKit/Support/Network/HTTP.swift @@ -23,19 +23,36 @@ class HTTP: NSObject { delegate: nil, delegateQueue: operationQueue) - static func requestValidation(from url: URL, token: String) async throws { - var request = URLRequest(url: url, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: .defaultHTTPConnect) - // Attach token to header - request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - // Make the data request + /// Perform one request on the shared session. + /// + /// Both parameters are applied to `request`, overriding whatever it carries, so the + /// transport owns them rather than each call site. + /// + /// - Parameter cachePolicy: Defaults to bypassing the cache: the shared session uses + /// `URLCache.shared`, and a cached validation or region response is a stale one. + /// - Parameter timeoutInterval: Defaults to ``TimeInterval/defaultHTTPConnect``. + static func request(_ request: URLRequest, + cachePolicy: URLRequest.CachePolicy = .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: TimeInterval = .defaultHTTPConnect) async throws -> (Data, HTTPURLResponse) + { + var request = request + request.cachePolicy = cachePolicy + request.timeoutInterval = timeoutInterval let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } + return (data, httpResponse) + } + + static func requestValidation(from url: URL, token: String) async throws { + var request = URLRequest(url: url) + // Attach token to header + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, httpResponse) = try await Self.request(request, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: .defaultHTTPConnect) guard (200 ..< 300).contains(httpResponse.statusCode) else { let statusCode = httpResponse.statusCode diff --git a/Sources/LiveKit/Support/Network/RustTransport.swift b/Sources/LiveKit/Support/Network/RustTransport.swift new file mode 100644 index 000000000..50593f7f8 --- /dev/null +++ b/Sources/LiveKit/Support/Network/RustTransport.swift @@ -0,0 +1,182 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +internal import LiveKitUniFFI +import Foundation + +/// Registers the SDK's own network stack as `livekit-net`'s transport, so anything the +/// Rust side sends goes out over the same ``WebSocket``/``HTTP`` session the Swift path +/// uses — one place tuning `networkServiceType`, multipath, TLS workarounds and logging. +/// +/// Lazily initialized on first access, like ``sharedLogger``: `livekit-net` keeps its +/// clients in a `OnceLock`, so this runs once and later accesses are free. +let rustTransport: Void = { + setWsClient(client: WsClientAdapter()) + setHttpClient(client: HttpClientAdapter()) +}() + +// MARK: - WebSocket + +final class WsClientAdapter: WsClient { + func connect(url: String, headers: [Header], timeoutMs: UInt64) async throws -> WsConnectResult { + guard let url = URL(string: url) else { + throw TransportError.Other("invalid url: \(url)") + } + do { + let socket = try await WebSocket(url: url, + headers: headers.asDictionary, + timeoutInterval: TimeInterval(timeoutMs) / 1000) + return WsConnectResult(connection: WsConnectionAdapter(socket)) + } catch { + throw error.asTransportError + } + } +} + +private final class WsConnectionAdapter: WsConnection { + private let socket: WebSocket + + init(_ socket: WebSocket) { + self.socket = socket + } + + func send(frame: Data) async throws { + do { + try await socket.send(data: frame) + } catch { + throw error.asTransportError + } + } + + func recv() async throws -> Data? { + do { + // Signalling is binary-only, so `for case let` skips any other frame kind and + // keeps reading. Iterating afresh per call is the same stream: the iterator + // buffers nothing, it reads straight off the task. + for try await case let .data(frame) in socket { + return frame + } + return nil + } catch { + let transportError = error.asTransportError + // livekit-net treats an abrupt teardown as end-of-stream, not an error: + // a reset without a close handshake, or TLS closed without close_notify. + if case .Closed = transportError { return nil } + throw transportError + } + } + + func close() async { + socket.close() + } +} + +// MARK: - HTTP + +final class HttpClientAdapter: HttpClient { + /// Translate one `livekit-net` HTTP call into a `URLRequest`. Cache policy and timeout + /// belong to ``HTTP/request(_:cachePolicy:timeoutInterval:)``, not here. + static func makeRequest(method: HttpMethod, url: URL, headers: [Header], body: Data?) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = switch method { + case .get: "GET" + case .post: "POST" + } + request.httpBody = body + for header in headers { + request.addValue(header.value, forHTTPHeaderField: header.name) + } + return request + } + + func request(method: HttpMethod, url: String, headers: [Header], body: Data?) async throws -> HttpResponse { + guard let url = URL(string: url) else { + throw TransportError.Other("invalid url: \(url)") + } + do { + // A 4xx/5xx is a response, not a transport failure — only the transport throws. + let request = Self.makeRequest(method: method, url: url, headers: headers, body: body) + let (data, response) = try await HTTP.request(request) + return HttpResponse(status: UInt16(clamping: response.statusCode), + headers: response.headers, + body: data) + } catch { + throw error.asTransportError + } + } +} + +// MARK: - Conversions + +private extension [Header] { + var asDictionary: [String: String] { + reduce(into: [:]) { $0[$1.name] = $1.value } + } +} + +private extension HTTPURLResponse { + /// - Note: `livekit-net` documents receipt order, which `HTTPURLResponse` does not keep. + var headers: [Header] { + allHeaderFields.compactMap { name, value in + guard let name = name as? String else { return nil } + return Header(name: name, value: String(describing: value)) + } + } +} + +extension Error { + /// Map onto `livekit-net`'s taxonomy, which the Rust signal client branches on: + /// `Http` is a rejected upgrade (fail fast), `Timeout`/`Closed`/`Connection` all + /// drive a reconnect. + var asTransportError: TransportError { + if let transportError = self as? TransportError { return transportError } + + let underlying = (self as? LiveKitError)?.internalError ?? self + + if let upgrade = underlying as? WebSocketUpgradeFailure { + return .Http(status: UInt16(clamping: upgrade.statusCode)) + } + if let urlError = underlying as? URLError { + switch urlError.code { + case .timedOut: + return .Timeout + // Our own `close()` surfaces as `.cancelled`; a peer reset arrives as + // `.networkConnectionLost`. Both are end-of-stream to livekit-net. + case .cancelled, .networkConnectionLost: + return .Closed + default: + // The numeric code, not `localizedDescription`: this string is what Rust + // logs and what gets grepped and grouped, and it must not change with the + // device's language. + return .Connection("URLError \(urlError.errorCode)") + } + } + let nsError = underlying as NSError + if nsError.domain == NSPOSIXErrorDomain, + nsError.code == Int(ECONNRESET) || nsError.code == Int(ENOTCONN) + { + return .Closed + } + if let type = (self as? LiveKitError)?.type { + switch type { + case .timedOut: return .Timeout + case .cancelled: return .Closed + default: break + } + } + return .Connection(String(describing: underlying)) + } +} diff --git a/Sources/LiveKit/Support/Network/WebSocket.swift b/Sources/LiveKit/Support/Network/WebSocket.swift index e4c4f951b..6210fea1a 100644 --- a/Sources/LiveKit/Support/Network/WebSocket.swift +++ b/Sources/LiveKit/Support/Network/WebSocket.swift @@ -17,6 +17,12 @@ import Foundation import Network +/// The server answered the upgrade request with an HTTP response instead of switching +/// protocols. Carried as the `internalError` of the thrown ``LiveKitError``. +struct WebSocketUpgradeFailure: Error, Sendable { + let statusCode: Int +} + actor WebSocket: Loggable, AsyncSequence { typealias Element = URLSessionWebSocketTask.Message @@ -37,11 +43,13 @@ actor WebSocket: Loggable, AsyncSequence { return config } - init(url: URL, token: String, connectOptions: ConnectOptions?) async throws { + init(url: URL, headers: [String: String], timeoutInterval: TimeInterval) async throws { var request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, - timeoutInterval: connectOptions?.socketConnectTimeoutInterval ?? .defaultSocketConnect) - request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + timeoutInterval: timeoutInterval) + for (name, value) in headers { + request.addValue(value, forHTTPHeaderField: name) + } #if targetEnvironment(simulator) if #available(iOS 26.0, *) { @@ -136,13 +144,21 @@ actor WebSocket: Loggable, AsyncSequence { } } - func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: Error?) { + func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { log("didCompleteWithError: \(String(describing: error))", error != nil ? .error : .debug) + // A rejected upgrade (401 on a bad token, 404 on a wrong path) arrives as a + // plain URLError with the HTTP response still attached. Keep the status: it is + // the difference between failing fast and reconnecting forever. + let upgradeStatus = (task.response as? HTTPURLResponse) + .map(\.statusCode) + .flatMap { $0 == 101 ? nil : WebSocketUpgradeFailure(statusCode: $0) } + _continuation.mutate { - if let error { - let lkError = LiveKitError.from(error: error) ?? LiveKitError(.unknown) - $0?.resume(throwing: lkError) + if let upgradeStatus { + $0?.resume(throwing: LiveKitError(.network, internalError: upgradeStatus)) + } else if let error { + $0?.resume(throwing: LiveKitError.from(error: error) ?? LiveKitError(.unknown)) } else { $0?.resume() } diff --git a/Tests/LiveKitCoreTests/RustTransportTests.swift b/Tests/LiveKitCoreTests/RustTransportTests.swift new file mode 100644 index 000000000..fe8c5cf42 --- /dev/null +++ b/Tests/LiveKitCoreTests/RustTransportTests.swift @@ -0,0 +1,263 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import LiveKit +import Testing +#if canImport(LiveKitTestSupport) +import LiveKitTestSupport +#endif +import LiveKitUniFFI + +/// Drives the registered transport from the Rust side, through the FFI, using +/// `livekit-net`'s own `self_test_*` exports — the same entry points the Rust +/// crate's `native_parity` suite uses against its bundled client. Nothing here +/// reaches into the adapter directly, so what is under test is the whole seam. +/// +/// Requires a local `livekit-server` (see AGENTS.md). +@Suite(.tags(.e2e, .networking)) +struct RustTransportTests { + private let httpUrl: String + private let wsUrl: String + + init() { + // `Room.init` forces this too; it runs once per process. + _ = rustTransport + wsUrl = TestEnvironment.liveKitServerUrl() + httpUrl = wsUrl + .replacingOccurrences(of: "wss://", with: "https://") + .replacingOccurrences(of: "ws://", with: "http://") + } + + @Test("Both clients are registered") + func registersBothClients() { + #expect(hasHttpClient()) + #expect(hasWsClient()) + } + + @Test("A GET crosses the FFI and comes back whole") + func httpGetRoundTrips() async throws { + let response = try await selfTestHttpGet(url: httpUrl) + + #expect(response.status == 200) + #expect(String(data: response.body, encoding: .utf8) == "OK") + // Headers are what let the Rust side read Cache-Control, so prove they survive. + #expect(!response.headers.isEmpty) + } + + @Test("An error status is a response, not a transport failure") + func errorStatusIsAResponse() async throws { + let response = try await selfTestHttpGet(url: "\(httpUrl)/no-such-endpoint") + + #expect(response.status == 404) + } + + /// The one mapping the Rust signal client branches on: `TransportError::Http` + /// becomes `SignalError::Handshake { status }` and fails fast, where a plain + /// `Connection` would drive an endless reconnect loop. + @Test("A rejected upgrade keeps its HTTP status") + func rejectedUpgradeKeepsItsStatus() async throws { + // No token on the signalling socket, so the server refuses the upgrade. + await #expect(throws: TransportError.Http(status: 401)) { + try await selfTestWsEcho(url: "\(wsUrl)/rtc", payload: Data([0x01])) + } + } + + /// `livekit-server` pushes a `JoinResponse` straight after the upgrade, so it is a + /// real peer that talks back: `self_test_ws_echo` connects, sends, receives one frame + /// and closes, all through the registered Swift transport. + @Test("A signalling socket connects, sends, receives and closes") + func webSocketRoundTrips() async throws { + let room = "rust-transport-\(UUID().uuidString)" + let token = try TestEnvironment.liveKitServerToken(for: room, identity: "rust-transport") + var components = try #require(URLComponents(string: "\(wsUrl)/rtc")) + components.queryItems = [URLQueryItem(name: "access_token", value: token), + URLQueryItem(name: "protocol", value: "15")] + let ping = try Livekit_SignalRequest.with { $0.ping = 1 }.serializedData() + + let frame = try await selfTestWsEcho(url: #require(components.string), payload: ping) + + let response = try Livekit_SignalResponse(serializedBytes: frame) + #expect(response.join.room.name == room) + } + + /// livekit-net's `recv` contract: `Ok(None)` for a gone peer, never an error. The + /// Rust read loop uses it to tell a clean teardown from a failure. + @Test("recv reports end-of-stream once the socket is closed") + func recvReportsEndOfStream() async throws { + let token = try TestEnvironment.liveKitServerToken(for: "rust-transport-eof", identity: "rust-transport") + let connection = try await WsClientAdapter() + .connect(url: "\(wsUrl)/rtc?protocol=15", + headers: [Header(name: "Authorization", value: "Bearer \(token)")], + timeoutMs: 10000) + .connection + + await connection.close() + + #expect(try await connection.recv() == nil) + } + + /// A `TransportError` thrown in Swift has to reach Rust as the same variant *with* + /// its payload. `.Http` and `.Connection` are pinned by the tests above; this pins + /// the string-carrying case, which is the one that can silently arrive empty. + @Test("A thrown TransportError keeps its payload across the FFI") + func thrownErrorKeepsItsPayload() async throws { + let error = await #expect(throws: TransportError.self) { + // Foundation percent-encodes most junk, so an empty string is the + // reliable way to fail `URL(string:)`. + try await selfTestHttpGet(url: "") + } + + guard case let .Other(message) = error else { + Issue.record("expected .Other, got \(String(describing: error))") + return + } + #expect(message.contains("invalid url")) + } + + /// A Swift-owned socket and a Rust-owned one to the same endpoint must not share + /// anything. Each ``WebSocket`` builds its own `URLSession`, so closing one calls + /// `finishTasksAndInvalidate()` on that session alone — pooling sessions (which Apple + /// otherwise recommends) would make the Rust socket's teardown kill the Swift one. + @Test("A Swift socket survives a Rust socket's whole lifecycle on the same URL") + func swiftAndRustSocketsAreIndependent() async throws { + let room = "rust-transport-\(UUID().uuidString)" + let rtcUrl = "\(wsUrl)/rtc?protocol=15" + + // Distinct identities: the same one twice is a duplicate join, and the server + // would evict one socket for us and hide the thing under test. + let swiftToken = try TestEnvironment.liveKitServerToken(for: room, identity: "swift-reader") + let swiftSocket = try await WebSocket(url: #require(URL(string: rtcUrl)), + headers: ["Authorization": "Bearer \(swiftToken)"], + timeoutInterval: 10) + defer { swiftSocket.close() } + var frames = swiftSocket.makeAsyncIterator() + #expect(try await joinedRoom(in: frames.next()) == room) + + let rustToken = try TestEnvironment.liveKitServerToken(for: room, identity: "rust-reader") + var components = try #require(URLComponents(string: "\(wsUrl)/rtc")) + components.queryItems = [URLQueryItem(name: "access_token", value: rustToken), + URLQueryItem(name: "protocol", value: "15")] + // Connects, sends, receives and closes — the close is what would take the + // Swift socket down with it if the two shared a session. + let rustFrame = try await selfTestWsEcho(url: #require(components.string), + payload: Livekit_SignalRequest.with { $0.ping = 1 }.serializedData()) + #expect(try Livekit_SignalResponse(serializedBytes: rustFrame).join.room.name == room) + + // The Swift socket still round-trips. `pingReq`, not the bare `ping`: only the + // former is answered by a current server (SignalClient sends both, for old ones). + let timestamp = Int64(2) + try await swiftSocket.send(data: Livekit_SignalRequest.with { + $0.pingReq = Livekit_Ping.with { $0.timestamp = timestamp } + }.serializedData()) + + var sawPong = false + while !sawPong, let frame = try await frames.next() { + guard case let .data(data) = frame else { continue } + let response = try Livekit_SignalResponse(serializedBytes: data) + sawPong = response.pongResp.lastPingTimestamp == timestamp || response.pong == timestamp + } + #expect(sawPong) + } + + /// The room a JOIN frame names. `#require` rather than an optional return, so a text + /// or absent frame reports itself instead of failing an unrelated equality. + private func joinedRoom(in message: URLSessionWebSocketTask.Message?) throws -> String { + guard case let .data(data) = try #require(message) else { + throw LiveKitError(.invalidState, message: "expected a binary frame, got \(message!)") + } + return try Livekit_SignalResponse(serializedBytes: data).join.room.name + } + + @Test("An unreachable peer is a connection error") + func unreachablePeerIsAConnectionError() async throws { + let error = await #expect(throws: TransportError.self) { + // Port 1 is reserved and never listening. + try await selfTestHttpGet(url: "http://127.0.0.1:1/") + } + + guard case let .Connection(message) = error else { + Issue.record("expected .Connection, got \(String(describing: error))") + return + } + // The URLError code is kept, not the device-localized description. + #expect(message.contains("URLError")) + } +} + +/// The mapping the whole seam hinges on, exercised where a socket cannot reach it: +/// `ECONNRESET` mid-frame and a bare `URLError(.timedOut)` are not things a healthy +/// `livekit-server` will produce on demand. +@Suite(.tags(.networking)) +struct TransportErrorMappingTests { + @Test("Errors map onto livekit-net's taxonomy", arguments: [ + // A rejected upgrade keeps its status, bare or wrapped by the WebSocket delegate. + (WebSocketUpgradeFailure(statusCode: 401), TransportError.Http(status: 401)), + (LiveKitError(.network, internalError: WebSocketUpgradeFailure(statusCode: 503)), .Http(status: 503)), + (URLError(.timedOut), .Timeout), + (LiveKitError(.timedOut), .Timeout), + // Every abrupt teardown livekit-net folds into end-of-stream. + (URLError(.cancelled), .Closed), + (URLError(.networkConnectionLost), .Closed), + (POSIXError(.ECONNRESET), .Closed), + (POSIXError(.ENOTCONN), .Closed), + (LiveKitError(.cancelled), .Closed), + // Already ours: crossing the FFI twice must not re-wrap. + (TransportError.Other("passthrough"), .Other("passthrough")), + ] as [(any Error & Sendable, TransportError)]) + func errorsMapOntoTheTaxonomy(error: any Error & Sendable, expected: TransportError) { + #expect(error.asTransportError == expected) + } + + @Test("A connection error keeps the URLError code, not a localized string") + func connectionErrorKeepsTheCode() { + let mapped = URLError(.secureConnectionFailed).asTransportError + + guard case let .Connection(message) = mapped else { + Issue.record("expected .Connection, got \(mapped)") + return + } + // -1200: a TLS trust failure has to stay distinguishable from a plain drop (#1074). + #expect(message.contains("\(URLError.Code.secureConnectionFailed.rawValue)")) + } +} + +/// `livekit-net` hands the Swift side a method, headers and a body; these are what +/// reach the wire. +@Suite(.tags(.networking)) +struct RustHTTPRequestTests { + private let url = URL(string: "https://example.test/path")! + + @Test("Every verb maps to its method", arguments: zip([HttpMethod.get, .post], ["GET", "POST"])) + func verbMapsToMethod(method: HttpMethod, expected: String) { + let request = HttpClientAdapter.makeRequest(method: method, url: url, headers: [], body: nil) + + #expect(request.httpMethod == expected) + } + + @Test("Headers and body are forwarded") + func headersAndBodyAreForwarded() { + let request = HttpClientAdapter.makeRequest(method: .post, + url: url, + headers: [Header(name: "Authorization", value: "Bearer t"), + Header(name: "X-Trace", value: "1")], + body: Data([0x01, 0x02])) + + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer t") + #expect(request.value(forHTTPHeaderField: "X-Trace") == "1") + #expect(request.httpBody == Data([0x01, 0x02])) + } +} diff --git a/Tests/LiveKitTestSupport/Room.swift b/Tests/LiveKitTestSupport/Room.swift index b8d2f84aa..7cc66eb3c 100644 --- a/Tests/LiveKitTestSupport/Room.swift +++ b/Tests/LiveKitTestSupport/Room.swift @@ -105,7 +105,7 @@ public final class RoomWatcher: RoomDelegate, Sendable // MARK: - Delegates - public func room(_: Room, participant _: RemoteParticipant?, didReceiveData data: Data, forTopic topic: String, encryptionType _: EncryptionType) { + public func room(_: Room, participant _: RemoteParticipant?, didReceiveData data: Data, forTopic topic: String, encryptionType _: LiveKit.EncryptionType) { // print("didReceiveData: \(data) for topic: \(topic)") Task { do { diff --git a/Tests/LiveKitTestSupport/TestEnvironment.swift b/Tests/LiveKitTestSupport/TestEnvironment.swift index 86dbaa62c..e9fd01ff9 100644 --- a/Tests/LiveKitTestSupport/TestEnvironment.swift +++ b/Tests/LiveKitTestSupport/TestEnvironment.swift @@ -31,13 +31,13 @@ public enum TestEnvironment { readEnvironmentString(for: "LIVEKIT_TESTING_URL", defaultValue: "ws://localhost:7880") } - // swiftlint:disable:next function_parameter_count + /// Grants default to the minimum needed to connect and observe; pass them explicitly to publish. public static func liveKitServerToken(for room: String, identity: String, - canPublish: Bool, - canPublishData: Bool, - canPublishSources: Set, - canSubscribe: Bool) throws -> String + canPublish: Bool = false, + canPublishData: Bool = false, + canPublishSources: Set = [], + canSubscribe: Bool = true) throws -> String { let apiKey = readEnvironmentString(for: "LIVEKIT_TESTING_API_KEY", defaultValue: "devkey") let apiSecret = readEnvironmentString(for: "LIVEKIT_TESTING_API_SECRET", defaultValue: "secret") From 7e7b64f5cc2efd1b731df0a05324ee94e9b3ed85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:51:08 +0200 Subject: [PATCH 2/4] Bump LiveKitUniFFI to 0.1.12 for the livekit-net bindings The released bindings name the setter argument `c`, not `client`. Co-Authored-By: Claude Opus 5 (1M context) --- Package.swift | 2 +- Package@swift-6.2.swift | 2 +- Sources/LiveKit/Support/Network/RustTransport.swift | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index 731e5d3d7..febf92e44 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ let package = Package( dependencies: [ // LK-Prefixed Dynamic WebRTC XCFramework .package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "150.7871.02"), - .package(url: "https://github.com/livekit/livekit-uniffi-xcframework.git", exact: "0.1.9"), + .package(url: "https://github.com/livekit/livekit-uniffi-xcframework.git", exact: "0.1.12"), // Test-only: conformance oracle for the nanopb facades. .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"), // Only used for DocC generation diff --git a/Package@swift-6.2.swift b/Package@swift-6.2.swift index 14c75afd4..69e308617 100644 --- a/Package@swift-6.2.swift +++ b/Package@swift-6.2.swift @@ -21,7 +21,7 @@ let package = Package( dependencies: [ // LK-Prefixed Dynamic WebRTC XCFramework .package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "150.7871.02"), - .package(url: "https://github.com/livekit/livekit-uniffi-xcframework.git", exact: "0.1.9"), + .package(url: "https://github.com/livekit/livekit-uniffi-xcframework.git", exact: "0.1.12"), // Test-only: conformance oracle for the nanopb facades. .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"), // Only used for DocC generation diff --git a/Sources/LiveKit/Support/Network/RustTransport.swift b/Sources/LiveKit/Support/Network/RustTransport.swift index 50593f7f8..cbf5d43cf 100644 --- a/Sources/LiveKit/Support/Network/RustTransport.swift +++ b/Sources/LiveKit/Support/Network/RustTransport.swift @@ -24,8 +24,8 @@ import Foundation /// Lazily initialized on first access, like ``sharedLogger``: `livekit-net` keeps its /// clients in a `OnceLock`, so this runs once and later accesses are free. let rustTransport: Void = { - setWsClient(client: WsClientAdapter()) - setHttpClient(client: HttpClientAdapter()) + setWsClient(c: WsClientAdapter()) + setHttpClient(c: HttpClientAdapter()) }() // MARK: - WebSocket From eaf3335d3438bc71c28564c77d5ce95e09e1e9d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:03:38 +0200 Subject: [PATCH 3/4] Handle unknown HttpMethod cases under library evolution The UniFFI enum is non-frozen, so a switch over it needs `@unknown default` once the module is built with library evolution enabled. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/LiveKit/Support/Network/RustTransport.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/LiveKit/Support/Network/RustTransport.swift b/Sources/LiveKit/Support/Network/RustTransport.swift index cbf5d43cf..8dd3185d8 100644 --- a/Sources/LiveKit/Support/Network/RustTransport.swift +++ b/Sources/LiveKit/Support/Network/RustTransport.swift @@ -94,6 +94,7 @@ final class HttpClientAdapter: HttpClient { request.httpMethod = switch method { case .get: "GET" case .post: "POST" + @unknown default: "GET" } request.httpBody = body for header in headers { From 5bfaf91bca33f467a99dfb494837230c5c8767c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:28:13 +0200 Subject: [PATCH 4/4] Bind the frame iterator with let Co-Authored-By: Claude Opus 5 (1M context) --- Tests/LiveKitCoreTests/RustTransportTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/LiveKitCoreTests/RustTransportTests.swift b/Tests/LiveKitCoreTests/RustTransportTests.swift index fe8c5cf42..e6be24c7e 100644 --- a/Tests/LiveKitCoreTests/RustTransportTests.swift +++ b/Tests/LiveKitCoreTests/RustTransportTests.swift @@ -144,7 +144,7 @@ struct RustTransportTests { headers: ["Authorization": "Bearer \(swiftToken)"], timeoutInterval: 10) defer { swiftSocket.close() } - var frames = swiftSocket.makeAsyncIterator() + let frames = swiftSocket.makeAsyncIterator() #expect(try await joinedRoom(in: frames.next()) == room) let rustToken = try TestEnvironment.liveKitServerToken(for: room, identity: "rust-reader")