Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
de1dae8
proto: update to 1.44
pblazej Feb 17, 2026
4e909c6
feat: add single peer connection support to Room and RoomOptions
pblazej Feb 17, 2026
161f5d8
feat: implement support for single peer connection mode
pblazej Feb 17, 2026
9149d2e
test: enable single peer connection in room options for test cases
pblazej Feb 17, 2026
10862f9
refactor: extract JoinRequest construction into a separate method
pblazej Feb 17, 2026
8cffacc
fix: select transport based on single peer connection state
pblazej Feb 17, 2026
53d7c3f
refactor: simplify transport selection with new receiveTransport prop…
pblazej Feb 17, 2026
bbb7d8b
proto: regenerate with newer version
pblazej Feb 17, 2026
1b9d016
refactor: unify transport state management using a structured enum
pblazej Feb 18, 2026
48b0a0f
change
pblazej Feb 18, 2026
8cd0335
test: update memory leak checks to use transport property in RoomTests
pblazej Feb 18, 2026
8735aae
refactor: move transport management logic into TransportMode enum
pblazej Feb 18, 2026
4810ec3
Merge branch 'main' into blaze/single-pc
pblazej Feb 18, 2026
a54ffb3
refactor: simplify transport management and TransportMode logic
pblazej Feb 19, 2026
2b98cf8
feat: implement v1 RTC path with automatic fallback to legacy path
pblazej Feb 20, 2026
1069328
fix: use established signal path version during quick reconnect
pblazej Feb 20, 2026
a55f7f5
refactor: remove validate and forceSecure options from URL construction
pblazej Feb 20, 2026
3fc6556
test: port peer connection signaling tests from Rust SDK
pblazej Feb 27, 2026
a5aa3f5
gitignore
pblazej Feb 27, 2026
78fb374
test: deepen audio and reconnect assertions to match Rust parity
pblazej Feb 27, 2026
dc828be
test: make single pc false in legacy tests
pblazej Mar 2, 2026
0b6e733
Revert "test: deepen audio and reconnect assertions to match Rust par…
pblazej Mar 2, 2026
505a594
fix(test): bypass frame-waiting in TestAudioTrack and use dummy pixel…
pblazej Mar 2, 2026
39702c0
fix: munge SDP inactive→recvonly for single PC mode
pblazej Mar 3, 2026
7b2130f
Merge branch 'main' into blaze/single-pc
pblazej Mar 3, 2026
fb20e80
fix(ci): improve test reliability and update CI matrix
pblazej Mar 6, 2026
cecef05
refactor: url validation cmt
pblazej Mar 19, 2026
cc9f0b7
Merge branch 'main' into blaze/single-pc
pblazej Mar 19, 2026
b64d296
fix(track): clear track proactively on unsubscribe
pblazej Mar 20, 2026
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/single-pc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
minor type="added" "Single peer connection mode via `RoomOptions.singlePeerConnection`. Requires LiveKit Cloud or LiveKit OSS >= 1.9.2."
31 changes: 16 additions & 15 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,38 +57,38 @@ jobs:

# https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
- os: macos-26
xcode: 26.1
platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.1"
xcode: 26.3
platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.2"
symbol-graph: true
- os: macos-26
xcode: 26.1
platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.1"
xcode: 26.3
platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.2"
extension-api-only: true
- os: macos-26
xcode: 26.1
xcode: 26.3
platform: "macOS"
symbol-graph: true
- os: macos-26
xcode: 26.1
xcode: 26.3
platform: "macOS"
asan: true
- os: macos-26
xcode: 26.1
xcode: 26.3
platform: "macOS"
tsan: true
- os: macos-26
xcode: 26.1
xcode: 26.3
platform: "macOS"
strict-concurrency-env: true
- os: macos-26
xcode: 26.1
xcode: 26.3
platform: "macOS,variant=Mac Catalyst"
- os: macos-26
xcode: 26.1
platform: "visionOS Simulator,name=Apple Vision Pro,OS=26.1"
xcode: 26.3
platform: "visionOS Simulator,name=Apple Vision Pro,OS=26.2"
- os: macos-26
xcode: 26.1
platform: "tvOS Simulator,name=Apple TV,OS=26.1"
xcode: 26.3
platform: "tvOS Simulator,name=Apple TV,OS=26.2"

runs-on: ${{ matrix.os }}
timeout-minutes: 60
Expand All @@ -102,7 +102,8 @@ jobs:
run: brew install livekit

- name: Run LiveKit Server
run: livekit-server --dev &
run: |
livekit-server --dev --config-body '{"room":{"departure_timeout":1}}' &

- uses: maxim-lobanov/setup-xcode@v1
with:
Expand All @@ -127,7 +128,7 @@ jobs:
- name: Run Tests
uses: nick-fields/retry@v3
env:
LIBDISPATCH_COOPERATIVE_POOL_STRICT: ${{ matrix.strict-concurrency-env == true && '1' || '' }}
LIBDISPATCH_COOPERATIVE_POOL_STRICT: ${{ matrix.strict-concurrency-env == true && '1' || '0' }}
with:
timeout_minutes: ${{ env.TEST_TIMEOUT_MINUTES }}
max_attempts: ${{ env.TEST_RETRY_ATTEMPTS }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cocoapods-lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- os: macos-15
xcode: 16.4
- os: macos-26
xcode: 26.1
xcode: 26.3
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
strategy:
matrix:
include:
- platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.1"
- platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.2"
artifact_name: symbol-graph-ios
- platform: "macOS"
artifact_name: symbol-graph-macos
Expand All @@ -38,7 +38,7 @@ jobs:
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.1"
xcode-version: "26.3"

- name: Build for Release - Symbol Graph
run: |
Expand Down Expand Up @@ -72,7 +72,7 @@ jobs:
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.1"
xcode-version: "26.3"

- name: Download Symbol Graphs
uses: actions/download-artifact@v4
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.DS_Store
/.build
.build
.claude
/Packages
/*.xcodeproj
.swiftpm/
Expand Down
105 changes: 62 additions & 43 deletions Sources/LiveKit/Core/Room+Engine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,11 @@ extension Room {
publisherDataChannel.reset()
subscriberDataChannel.reset()

let (subscriber, publisher) = _state.read { ($0.subscriber, $0.publisher) }

// Close transports
await publisher?.close()
await subscriber?.close()
await _state.transport?.close()

// Reset publish state
_state.mutate {
$0.subscriber = nil
$0.publisher = nil
$0.transport = nil
$0.hasPublished = false
}
}
Expand All @@ -75,7 +70,10 @@ extension Room {

func send(dataPacket packet: Livekit_DataPacket) async throws {
func ensurePublisherConnected() async throws {
guard _state.isSubscriberPrimary else { return }
// Only needed when subscriber is primary in dual PC mode
guard case .subscriberPrimary = _state.transport else {
return
}

let publisher = try requirePublisher()

Expand All @@ -91,7 +89,7 @@ extension Room {
try await ensurePublisherConnected()

// At this point publisher should be .connected and dc should be .open
if await !(_state.publisher?.isConnected ?? false) {
if await !(_state.transport?.publisher.isConnected ?? false) {
log("publisher is not .connected", .error)
}

Expand All @@ -116,7 +114,7 @@ extension Room {

extension Room {
// swiftlint:disable:next function_body_length
func configureTransports(connectResponse: SignalClient.ConnectResponse) async throws {
func configureTransports(connectResponse: SignalClient.ConnectResponse, singlePeerConnection: Bool) async throws {
func makeConfiguration() -> LKRTCConfiguration {
let connectOptions = _state.connectOptions

Expand Down Expand Up @@ -147,23 +145,20 @@ extension Room {
if case let .join(joinResponse) = connectResponse {
log("Configuring transports with JOIN response...")

guard _state.subscriber == nil, _state.publisher == nil else {
guard _state.transport == nil else {
log("Transports are already configured")
return
}

// protocol v3
let isSubscriberPrimary = joinResponse.subscriberPrimary
log("subscriberPrimary: \(joinResponse.subscriberPrimary)")

let subscriber = try Transport(config: rtcConfiguration,
target: .subscriber,
primary: isSubscriberPrimary,
delegate: self)
let isSinglePC = singlePeerConnection
let isSubscriberPrimary = isSinglePC ? false : joinResponse.subscriberPrimary
log("subscriberPrimary: \(isSubscriberPrimary), singlePeerConnection: \(isSinglePC)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the log intentional ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's .debug and it existed before: log("subscriberPrimary: \(joinResponse.subscriberPrimary)")


// Publisher always created; is primary in single PC mode
let publisher = try Transport(config: rtcConfiguration,
target: .publisher,
primary: !isSubscriberPrimary,
primary: isSinglePC || !isSubscriberPrimary,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curiously, what does primary mean here ? why primary is true when isSinglePC is true ?

Copy link
Contributor Author

@pblazej pblazej Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it maps the inverse relationship between the TransportMode and Transport, mostly in Room+TransportDelegate (transport wouldn't know if it's "the only one" in single PC mode):

    func transport(_ transport: Transport, didUpdateState pcState: LKRTCPeerConnectionState) {
        log("target: \(transport.target), connectionState: \(pcState.description)")

        // primary connected
        if transport.isPrimary {
            if pcState.isConnected {
                primaryTransportConnectedCompleter.resume(returning: ())

singlePCMode: isSinglePC,
delegate: self)

await publisher.set { [weak self] offer, offerId in
Expand All @@ -186,23 +181,29 @@ extension Room {
log("dataChannel.\(String(describing: reliableDataChannel?.label)) : \(String(describing: reliableDataChannel?.channelId))")
log("dataChannel.\(String(describing: lossyDataChannel?.label)) : \(String(describing: lossyDataChannel?.channelId))")

_state.mutate {
$0.subscriber = subscriber
$0.publisher = publisher
$0.isSubscriberPrimary = isSubscriberPrimary
let subscriber = isSinglePC ? nil : try Transport(config: rtcConfiguration,
target: .subscriber,
primary: isSubscriberPrimary,
delegate: self)

let transport: TransportMode = if let subscriber, isSubscriberPrimary {
.subscriberPrimary(publisher: publisher, subscriber: subscriber)
} else if let subscriber {
.publisherPrimary(publisher: publisher, subscriber: subscriber)
} else {
.publisherOnly(publisher: publisher)
}
_state.mutate { $0.transport = transport }

log("[Connect] Fast publish enabled: \(joinResponse.fastPublish ? "true" : "false")")
if !isSubscriberPrimary || joinResponse.fastPublish {
// lazy negotiation for protocol v3+
if isSinglePC || !isSubscriberPrimary || joinResponse.fastPublish {
// In single PC mode or when publisher is primary, negotiate immediately
try await publisherShouldNegotiate()
}

} else if case let .reconnect(reconnectResponse) = connectResponse {
log("[Connect] Configuring transports with RECONNECT response...")
let (subscriber, publisher) = _state.read { ($0.subscriber, $0.publisher) }
try await subscriber?.set(configuration: rtcConfiguration)
try await publisher?.set(configuration: rtcConfiguration)
try await _state.transport?.set(configuration: rtcConfiguration)
publisherDataChannel.retryReliable(lastSequence: reconnectResponse.lastMessageSeq)
}
}
Expand Down Expand Up @@ -249,16 +250,32 @@ public enum StartReconnectReason: Sendable {
extension Room {
// full connect sequence, doesn't update connection state
func fullConnectSequence(_ url: URL, _ token: String) async throws {
let connectResponse = try await signalClient.connect(url,
var singlePC = _state.roomOptions.singlePeerConnection

let connectResponse: SignalClient.ConnectResponse
do {
connectResponse = try await signalClient.connect(url,
token,
connectOptions: _state.connectOptions,
reconnectMode: _state.isReconnectingWithMode,
adaptiveStream: _state.roomOptions.adaptiveStream)
adaptiveStream: _state.roomOptions.adaptiveStream,
singlePeerConnection: singlePC)
} catch let error as LiveKitError where error.type == .serviceNotFound && singlePC {
log("v1 RTC path not supported, retrying with legacy path", .warning)
singlePC = false
connectResponse = try await signalClient.connect(url,
token,
connectOptions: _state.connectOptions,
reconnectMode: _state.isReconnectingWithMode,
adaptiveStream: _state.roomOptions.adaptiveStream,
singlePeerConnection: false)
}

// Check cancellation after WebSocket connected
try Task.checkCancellation()

_state.mutate { $0.connectStopwatch.split(label: "signal") }
try await configureTransports(connectResponse: connectResponse)
try await configureTransports(connectResponse: connectResponse, singlePeerConnection: singlePC)
// Check cancellation after configuring transports
try Task.checkCancellation()

Expand Down Expand Up @@ -288,8 +305,8 @@ extension Room {
throw LiveKitError(.invalidState)
}

guard _state.subscriber != nil, _state.publisher != nil else {
log("[Connect] Publisher or subscriber is nil", .error)
guard _state.transport != nil else {
log("[Connect] Transport is nil", .error)
throw LiveKitError(.invalidState)
}

Expand All @@ -308,16 +325,19 @@ extension Room {
@Sendable func quickReconnectSequence() async throws {
log("[Connect] Starting .quick reconnect sequence...")

let singlePC = await !signalClient.useV0SignalPath
let connectResponse = try await signalClient.connect(url,
token,
connectOptions: _state.connectOptions,
reconnectMode: _state.isReconnectingWithMode,
participantSid: localParticipant.sid,
adaptiveStream: _state.roomOptions.adaptiveStream)
adaptiveStream: _state.roomOptions.adaptiveStream,
singlePeerConnection: singlePC)
try Task.checkCancellation()

// Update configuration
try await configureTransports(connectResponse: connectResponse)
try await configureTransports(connectResponse: connectResponse,
singlePeerConnection: singlePC)
try Task.checkCancellation()

// Resume after configuring transports...
Expand All @@ -337,9 +357,9 @@ extension Room {
// send SyncState before offer
try await sendSyncState()

await _state.subscriber?.setIsRestartingIce()
await _state.transport?.setSubscriberRestartingIce()

if let publisher = _state.publisher, _state.hasPublished {
if let publisher = _state.transport?.publisher, _state.hasPublished {
// Only if published, wait for publisher to connect...
log("[Connect] Waiting for publisher to connect...")
try await publisher.createAndSendOffer(iceRestart: true)
Expand Down Expand Up @@ -473,13 +493,12 @@ extension Room {

extension Room {
func sendSyncState() async throws {
guard let subscriber = _state.subscriber else {
log("Subscriber is nil", .error)
guard let transport = _state.transport else {
log("Transport is nil", .error)
return
}

let previousAnswer = await subscriber.localDescription
let previousOffer = await subscriber.remoteDescription
let (previousAnswer, previousOffer) = await transport.syncStateDescriptions()

// 1. autosubscribe on, so subscribed tracks = all tracks - unsub tracks,
// in this case, we send unsub tracks, so server add all tracks to this
Expand Down Expand Up @@ -517,7 +536,7 @@ extension Room {

extension Room {
func requirePublisher() throws -> Transport {
guard let publisher = _state.publisher else {
guard let publisher = _state.transport?.publisher else {
log("Publisher is nil", .error)
throw LiveKitError(.invalidState, message: "Publisher is nil")
}
Expand Down
Loading
Loading