Skip to content

Bundle the publisher offer with the JoinRequest (offer-with-join) - #1111

Open
xianshijing-lk wants to merge 1 commit into
sxian/CLT-3301/fix-join-request-encoding-and-add-gzip-compression-to-the-v1-signalfrom
sxian/CLT-3302/bundle-the-publisher-offer-with-the-joinrequest-offer-with-join
Open

Bundle the publisher offer with the JoinRequest (offer-with-join)#1111
xianshijing-lk wants to merge 1 commit into
sxian/CLT-3301/fix-join-request-encoding-and-add-gzip-compression-to-the-v1-signalfrom
sxian/CLT-3302/bundle-the-publisher-offer-with-the-joinrequest-offer-with-join

Conversation

@xianshijing-lk

Copy link
Copy Markdown
Contributor

Fixes CLT-2202.

Stacked on #1110 (sxian/CLT-3301/...). Base is that branch, so the diff shows only this change. Retarget to main once #1110 merges.

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.publisherOffer existed 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-549 and :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 before signalClient.connect. Only in single PC mode, where the publisher is primary, so its primary/singlePCMode values 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.make installs the server configuration onto the adopted transport, and set(remoteDescription:) applies the pending offer when the answer lands. Same mechanism as pendingInitialOffer in JS and pending_initial_offer in 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 shared PublisherDataChannels so both paths negotiate the same layout.

Skipping the eager negotiate — when the offer was bundled, fullConnectSequence marks hasPublished and does not call publisherShouldNegotiate, matching Rust's sent_publisher_offer branch. createAndSendOffer also 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.1 locally — 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:

  • the join request carried the offer: "PublisherOffer": {"type": "offer"...}
  • zero separate received offer messages over the signal channel for that connection

A dual-PC session in the same run shows the opposite — an empty PublisherOffer and a separate received offer with offerId: 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:

  • No initial offer outside single PC mode
  • The offer is produced with offerId == 1 and a non-empty SDP while localDescription stays nil and signaling state stays .stable — then applying the answer sets localDescription
  • Clearing the pending offer restores ordinary negotiation
  • Negotiation defers while the initial offer is pending, and the queued offer is released once the answer is applied
  • The offer round-trips through the join_request parameter; absent when there is none

Notes for review

  1. Server compatibility is the main thing to confirm beyond localhost. If a server serves /rtc/v1 but ignores publisherOffer, 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), and publisherOffer shipped with the v1 path — but it's worth a check against staging/production Cloud before enabling single-PC by default (CLT ticket 4).

  2. 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 on createInitialOffer().

  3. 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() and defineAndGetSchema() fail locally. I verified they fail identically on origin/main with none of these changes — local livekit-server 1.13.1 appears not to support data-track schema metadata. CooperativePoolBlockingTests passes 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. swiftlint and swiftformat --lint clean. No public API change.

🤖 Generated with Claude Code

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>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Devin Review

Comment on lines +190 to +192
// Before the state check: an offer bundled with JOIN leaves the connection
// `.stable` until its answer arrives.
try await applyPendingInitialOffer()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@xianshijing-lk

Copy link
Copy Markdown
Contributor Author

Benchmarked and validated against staging Cloud

E2E

PeerConnectionSignalingTests — 9/9 passed, both PC modes (50.2s)

Against wss://xianstaging-hixkk74p.staging.livekit.cloud. This is the real-Cloud confirmation the description flagged as the main outstanding risk ("a server that serves /rtc/v1 but ignores publisherOffer"). Staging answers the bundled offer correctly.

Connect-time benchmark

BM-CONN-001 / BM-CONN-003 from Benchmarks/, 25 iterations + 5 warmups each, same staging server:

Config wall p50 wall p90 D_WS p50 D_TRANSPORT p50
main + dual PC (ships today) 508 553 206 298
main + single PC 511 675 212 291
this stack + dual PC 514 552 214 298
this stack + single PC 482 555 219 242

D_TRANSPORT_MS (join_recv → pc_connected) is the metric to read — D_WS_MS is pure TLS/WS handshake and is flat across all four configs, so it is the noise floor.

~50 ms (−17%) off the transport phase, 291 → 242 versus single PC on main. That is one round trip, and it matches the RTT implied by D_WS_MS ≈ 206 ms spanning ~3–4 RTTs. Since the saving is one RTT, it scales with client-to-region latency — larger on the high-RTT paths that motivated this work.

Two things worth recording:

  1. Single PC alone buys nothing (291 vs 298 on main). The gain is entirely offer-with-join, not the topology change. This PR and the default flip are only valuable together.
  2. No dual-PC regression. The first dual-PC run on this branch read 348 ms, which looked like a regression; re-running gave 298 — identical to main. It was run-to-run variance. Worth noting for anyone benchmarking this: a single 25-iteration run against remote Cloud has enough variance that a ~50 ms p50 delta is not trustworthy on its own.

D_ICE_DTLS_MS is absent in the single-PC-with-this-stack row because there is no offer_sent span — the offer travelled in the join URL. That is independent confirmation the mechanism is active.

Reproduce

cd 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"

--filter requires the exact full benchmark name; a prefix silently matches nothing and exits 0 with an empty table.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant