Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/websocket-close-frame-reconnects
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="changed" "A close frame from the server fails the signalling stream with its close code, so the client reconnects at once instead of after the ping timeout; our own close still ends it"
1 change: 1 addition & 0 deletions .changes/websocket-network-framework
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "Stop leaking about 25 KB per signalling connection: the WebSocket runs over Network.framework, as Foundation keeps every URLSession that ran a WebSocket task alive after it is closed; a rejected upgrade still reports its HTTP status. The signalling connection no longer goes through a system HTTP or PAC proxy, which URLSession honoured"
4 changes: 4 additions & 0 deletions Sources/LiveKit/Support/Network/RustTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ extension Error {

let underlying = (self as? LiveKitError)?.internalError ?? self

// livekit-net reads a close as the end of the stream, as it always has.
if underlying is WebSocketClosedByPeer {
return .Closed
}
if let upgrade = underlying as? WebSocketUpgradeFailure {
return .Http(status: UInt16(clamping: upgrade.statusCode))
}
Expand Down
283 changes: 173 additions & 110 deletions Sources/LiveKit/Support/Network/WebSocket.swift

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions Tests/LiveKitCoreTests/Room/ServerRemovalTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* 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 LiveKitFFI
import Testing
#if canImport(LiveKitTestSupport)
import LiveKitTestSupport
#endif

/// The server's own end of a session is a leave and then a close frame. A close frame fails the signalling stream, so
/// that the client reconnects when the server drops it; it must not turn a removal into a reconnect.
@Suite(.serialized, .tags(.e2e), TestLimits.e2e) final class ServerRemovalTests: @unchecked Sendable {
private let reconnects = StateSync(0)
private let disconnects = StateSync(0)

@Test func aRemovedParticipantDisconnectsWithoutReconnecting() async throws {
try await TestEnvironment.withRoom(RoomTestingOptions(delegate: self)) { room in
let name = try #require(room.name)
let identity = try #require(room.localParticipant.identity?.stringValue)
try await Self.removeParticipant(identity, from: name)
try await poll(timeout: 10, for: "the removed participant's room to disconnect") {
self.disconnects.copy() > 0
}
#expect(room.connectionState == .disconnected)
#expect(self.reconnects.copy() == 0)
}
}

/// RoomService.RemoveParticipant over Twirp, with a room admin token.
private static func removeParticipant(_ identity: String, from room: String) async throws {
let generator = TokenGenerator(apiKey: TestEnvironment.readEnvironmentString(for: "LIVEKIT_TESTING_API_KEY", defaultValue: "devkey"),
apiSecret: TestEnvironment.readEnvironmentString(for: "LIVEKIT_TESTING_API_SECRET", defaultValue: "secret"),
identity: "admin")
generator.videoGrants = VideoGrants(
roomCreate: false,
roomList: false,
roomRecord: false,
roomAdmin: true,
roomJoin: false,
room: room,
destinationRoom: "",
canPublish: false,
canSubscribe: false,
canPublishData: false,
canPublishSources: [],
canUpdateOwnMetadata: false,
ingressAdmin: false,
hidden: false,
recorder: false,
agent: false,
)
let base = TestEnvironment.liveKitServerUrl()
.replacingOccurrences(of: "wss://", with: "https://")
.replacingOccurrences(of: "ws://", with: "http://")
var request = try URLRequest(url: #require(URL(string: "\(base)/twirp/livekit.RoomService/RemoveParticipant")))
request.httpMethod = "POST"
request.addValue("Bearer \(try generator.sign())", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: ["room": room, "identity": identity])
let (_, response) = try await URLSession.shared.data(for: request)
#expect((response as? HTTPURLResponse)?.statusCode == 200)
}
}

extension ServerRemovalTests: RoomDelegate {
func room(_: Room, didStartReconnectWithMode _: ReconnectMode) {
reconnects.mutate { $0 += 1 }
}

func roomIsReconnecting(_: Room) {
reconnects.mutate { $0 += 1 }
}

func room(_: Room, didDisconnectWithError _: LiveKitError?) {
disconnects.mutate { $0 += 1 }
}
}
19 changes: 3 additions & 16 deletions Tests/LiveKitCoreTests/RustTransportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@

import Foundation
@testable import LiveKit
import LiveKitFFI
import Network
import Testing
#if canImport(LiveKitTestSupport)
import LiveKitTestSupport
#endif
import LiveKitFFI

/// 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
Expand Down Expand Up @@ -215,6 +216,7 @@ struct TransportErrorMappingTests {
(URLError(.networkConnectionLost), .Closed),
(POSIXError(.ECONNRESET), .Closed),
(POSIXError(.ENOTCONN), .Closed),
(LiveKitError(.network, internalError: WebSocketClosedByPeer(.protocolCode(.normalClosure))), .Closed),
(LiveKitError(.cancelled), .Closed),
// Already ours: crossing the FFI twice must not re-wrap.
(TransportError.Other("passthrough"), .Other("passthrough")),
Expand Down Expand Up @@ -262,18 +264,3 @@ struct RustHTTPRequestTests {
#expect(request.httpBody == Data([0x01, 0x02]))
}
}

/// `livekit-net` hands the Swift side an ordered header list for the WebSocket upgrade.
@Suite(.tags(.networking))
struct RustWebSocketRequestTests {
@Test("Repeated header names keep every value")
func repeatedHeadersKeepEveryValue() throws {
let request = try WebSocket.makeRequest(url: #require(URL(string: "wss://example.test/rtc")),
headers: [(name: "Sec-WebSocket-Protocol", value: "a"),
(name: "Sec-WebSocket-Protocol", value: "b"),
(name: "sec-websocket-protocol", value: "c")],
timeoutInterval: 10)

#expect(request.value(forHTTPHeaderField: "Sec-WebSocket-Protocol") == "a,b,c")
}
}
Loading
Loading