Bundle the publisher offer with the JoinRequest (offer-with-join) - #1111
Conversation
In single peer connection mode the publisher offer is now created before the signal socket opens and carried in the JoinRequest, so the server answers it in the same exchange. That removes a client<->server round trip from the connect path, and building the peer connection up front moves the WebRTC cold start (SSL init, peer connection factory, audio device module) off it as well -- it now overlaps the TLS/WebSocket handshake. setLocalDescription is deferred until the answer arrives: applying it starts ICE gathering, and at creation time the connection only has the client-side configuration, so it would gather without the server's TURN servers. JoinDependencies adopts the early publisher and installs the server's configuration onto it, which is what releases the deferred offer. The early publisher is owned lexically by the connect sequence rather than being a stage payload, so the stage invariant -- staged transports exist if and only if the stage is .connected -- still holds. It is closed on every exit before the JOIN is applied, including the v1 -> v0 fallback, where the legacy path needs a publisher with different immutable properties. Ports rtc_session.rs:511-549 and :703-710 from rust-sdks, and the equivalent path in client-sdk-js RTCEngine.ts:334-396. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // Before the state check: an offer bundled with JOIN leaves the connection | ||
| // `.stable` until its answer arrives. | ||
| try await applyPendingInitialOffer() |
There was a problem hiding this comment.
🟡 Mismatched answers strand the connection
A mismatched answer ID makes applyPendingInitialOffer() consume the pending offer before validation rejects the answer. The connection then remains stuck until timeout.
Prompt for agents
In Sources/LiveKit/Core/Transport.swift, set(remoteDescription:offerId:) applies and clears the deferred initial offer before checking offerId. A stale or incorrect answer therefore moves the peer connection to haveLocalOffer and removes the deferred state, then fails validation without applying a remote answer. Reorder the operation so offer ID validation occurs before any peer-connection or pending-offer mutation. Preserve the requirement that the deferred local offer is applied before the valid remote answer.
Was this helpful? React with 👍 or 👎 to provide feedback.
Benchmarked and validated against staging CloudE2EAgainst Connect-time benchmark
~50 ms (−17%) off the transport phase, 291 → 242 versus single PC on Two things worth recording:
Reproducecd Benchmarks
LK_BENCHMARK=1 LIVEKIT_URL=wss://… LIVEKIT_API_KEY=… LIVEKIT_API_SECRET=… \
swiftly run +xcode swift package --disable-sandbox benchmark \
--filter "BM-CONN-003-SinglePC"
|
Fixes CLT-2202.
Swift performed one more client↔server round trip on connect than JS or Rust, and serialized the WebRTC cold start into the middle of the connect sequence. A customer reports iOS/Android p50 connect of 800 ms in Asia vs 400 ms in US; each removed round trip is worth proportionally more on high-RTT paths.
Livekit_JoinRequest.publisherOfferexisted in the protos but was never populated. Swift waited for the join response, then created the peer connections, then negotiated. Both other SDKs create the PC and initial offer before opening the socket and bundle the offer into the join request (rust-sdks/livekit/src/rtc_engine/rtc_session.rs:511-549and:703-710;client-sdk-js/src/room/RTCEngine.ts:334-396).What changed
EarlyPublisher(new,RoomDependencies.swift) — builds the publisher transport, its three data channels, and the initial offer beforesignalClient.connect. Only in single PC mode, where the publisher is primary, so itsprimary/singlePCModevalues are known without the join response.Deferred
setLocalDescription(Transport.createInitialOffer()) — the offer is produced and signalled but not applied. Applying it starts ICE gathering, and at that point the connection only has the client-side configuration, so it would gather without the server's TURN servers and never produce relay candidates.JoinDependencies.makeinstalls the server configuration onto the adopted transport, andset(remoteDescription:)applies the pending offer when the answer lands. Same mechanism aspendingInitialOfferin JS andpending_initial_offerin Rust.JoinDependencies.make(…, earlyPublisher:)— adopts the early publisher instead of rebuilding it, or creates one as before when there is none. Data channel creation moved into a sharedPublisherDataChannelsso both paths negotiate the same layout.Skipping the eager negotiate — when the offer was bundled,
fullConnectSequencemarkshasPublishedand does not callpublisherShouldNegotiate, matching Rust'ssent_publisher_offerbranch.createAndSendOfferalso treats a pending initial offer as "awaiting an answer", so a publish racing the JOIN queues a renegotiation instead of offering over the top of it.Ownership — the early publisher is a local in the connect sequence, deliberately not a stage payload, so the documented stage invariant (staged transports exist iff the stage is
.connected) still holds. It is closed on every exit before the JOIN is applied, including the v1→v0 fallback, where the legacy path needs a publisher with different immutable properties and so cannot reuse it.Verified against a real server
Ran the E2E signaling suite against
livekit-server 1.13.1locally — all 9 tests pass in both dual-PC and single-PC modes (connect, two participants, audio track, data channel, quick reconnect, full reconnect, double reconnect, publish-many-tracks, v1→v0 fallback).Server logs confirm the mechanism actually engages rather than silently falling back. For a single-PC session:
"PublisherOffer": {"type": "offer"...}received offermessages over the signal channel for that connectionA dual-PC session in the same run shows the opposite — an empty
PublisherOfferand a separatereceived offerwithofferId: 1. So the round trip is genuinely removed, not duplicated.Unit tests
New
OfferWithJoinTests(6 tests) using two local peer connections, one standing in for the SFU:offerId == 1and a non-empty SDP whilelocalDescriptionstaysniland signaling state stays.stable— then applying the answer setslocalDescriptionjoin_requestparameter; absent when there is noneNotes for review
Server compatibility is the main thing to confirm beyond localhost. If a server serves
/rtc/v1but ignorespublisherOffer, no answer ever arrives, ICE never starts, and the connect fails on the transport timeout. Rust has no guard for this either (JS gates only on a browser capability), andpublisherOffershipped with the v1 path — but it's worth a check against staging/production Cloud before enabling single-PC by default (CLT ticket 4).Munge fallback does not apply to this path.
set(localDescription:munging:)normally drops a munge libwebrtc rejects and retries; a bundled offer can't, since the peer has already been told what we offered. Both munges here (mungeInactiveToRecvOnlyForMedia,mungeOpusStereoForAllAudio) are the ones every single-PC offer already carries, so this isn't a new risk class, but it is a behavior difference worth a look. Documented oncreateInitialOffer().Synergy with Fix join_request encoding and add gzip compression to the v1 signal URL #1110: real join requests now carry an SDP, so they are large enough that the gzip added in Fix join_request encoding and add gzip compression to the v1 signal URL #1110 engages — ~3032 B → ~592 B of URL for a 4-section offer, keeping the upgrade request inside one TCP segment.
Pre-existing failures (not from this change)
DataTrackPublishTests.publishWithFrameMetadata()anddefineAndGetSchema()fail locally. I verified they fail identically onorigin/mainwith none of these changes — locallivekit-server 1.13.1appears not to support data-track schema metadata.CooperativePoolBlockingTestspasses in isolation; it only failed as collateral when a screen-share E2E test hung the full-suite run on missing screen-recording permission.Builds verified on macOS, Mac Catalyst and iOS Simulator.
swiftlintandswiftformat --lintclean. No public API change.🤖 Generated with Claude Code