Skip to content

Fix join_request encoding and add gzip compression to the v1 signal URL - #1110

Open
xianshijing-lk wants to merge 1 commit into
mainfrom
sxian/CLT-3301/fix-join-request-encoding-and-add-gzip-compression-to-the-v1-signal
Open

Fix join_request encoding and add gzip compression to the v1 signal URL#1110
xianshijing-lk wants to merge 1 commit into
mainfrom
sxian/CLT-3301/fix-join-request-encoding-and-add-gzip-compression-to-the-v1-signal

Conversation

@xianshijing-lk

Copy link
Copy Markdown
Contributor

Fixes CLT-2201.

The v1 signal path (/rtc/v1) carries the JoinRequest protobuf as a base64 query parameter, built in Utils.buildWrappedJoinRequest. Two things diverged from client-sdk-js and rust-sdks.

1. Standard base64 instead of base64url

wrappedData.base64EncodedString() emits + and /. URLComponents percent-encodes = but leaves + and / literal in the query:

wss://host/rtc/v1?join_request=ab+cd/ef%3Dgh

A receiver that parses the query as form-urlencoded decodes + as a space, which corrupts the payload. Go's net/url does exactly this. Both other SDKs avoid it deliberately:

  • rust-sdks: BASE64_URL_SAFE.encode(&wrapped_bytes)"URL-safe base64 avoids percent-encoding issues in query parameters" (livekit-signaling/src/lib.rs:876-879)
  • client-sdk-js: .replace(/\+/g, '-').replace(/\//g, '_') (src/api/SignalClient.ts:1457)

This has likely gone unnoticed because the v1 path only runs with RoomOptions.singlePeerConnection = true, which is false by default in Swift. This is the part that needs a confirming run against a real server — the intent in the other two SDKs is unambiguous, but I have not reproduced the server-side corruption end to end. Padding is preserved, matching both other SDKs.

2. Compression was hardcoded to .none

The request travels in the WebSocket upgrade URL, so an oversized request splits across TCP segments — on a lossy path one lost segment costs an RTO before the handshake begins. Rust always gzips and keeps the compressed form only when smaller (lib.rs:853-871); JS gzips whenever CompressionStream exists (SignalClient.ts:1425-1450). This adopts the same rule.

Apple's Compression framework has no gzip container — COMPRESSION_ZLIB produces a raw DEFLATE stream with no header, checksum or length trailer — so Gzip wraps it in RFC 1952 framing with a table-driven CRC-32 rather than linking zlib solely for its container. The mtime field is left zero so encoding is deterministic and no clock value is disclosed.

What this actually saves today

Measured with the same encoder on realistic payloads:

payload base64 in URL, uncompressed with gzip
minimal join request (today) 44 B 68 B → stays uncompressed
join request + 4-section SDP publisher offer 3032 B 592 B

So there is no change on the wire today beyond the encoding fix: a minimal join request is smaller than gzip's framing, so the size comparison correctly declines to compress it (covered by smallRequestIsNotCompressed). The payoff arrives with offer-with-join (CLT ticket 2), where the SDP makes the payload multi-KB — which is why JS gates offer-with-join on compression being available (isPublisherOfferWithJoinSupported = isCompressionStreamSupported() && !isFireFox()). Landing this first unblocks that work.

Testing

New JoinRequestUrlTests / GzipTests (8 tests, all passing):

  • join_request contains no + or / in the percent-encoded query
  • Round-trips through base64url → WrappedJoinRequest → gunzip → JoinRequest, for both fresh connect and quick reconnect
  • A minimal request stays .none
  • CRC-32 known-answer vector ("123456789"0xCBF43926), and empty input → 0
  • Gzip magic + method bytes, CRC and ISIZE trailer values, and a full decompress round-trip
  • Incompressible (random) input still produces a decodable stream, and empty input returns nil

Builds verified on macOS, Mac Catalyst and iOS Simulator; tvOS/visionOS SDKs aren't installed locally, so CI covers those (the Compression framework is available on all of them).

swiftlint and swiftformat --lint clean. No public API change — Gzip and the Data extension are internal.

🤖 Generated with Claude Code

The v1 signal path carries the JoinRequest protobuf as a query parameter,
and Utils.buildWrappedJoinRequest diverged from client-sdk-js and
rust-sdks in two ways.

It emitted standard base64, whose `+` and `/` URLComponents leaves
unescaped in a query value. A receiver that parses the query as
form-urlencoded decodes `+` as a space, corrupting the payload. Both
other SDKs deliberately use the URL-safe alphabet.

It also hardcoded compression to .none. The request travels in the
WebSocket upgrade URL, so an oversized request splits across TCP
segments and a single loss costs a retransmission timeout before the
handshake starts. Rust and JS both gzip, keeping the compressed form
only when it is actually smaller.

Apple's Compression framework has no gzip container -- COMPRESSION_ZLIB
produces raw DEFLATE -- so Gzip wraps it in RFC 1952 framing with a
CRC-32 rather than linking zlib for its container alone.

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: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@xianshijing-lk

Copy link
Copy Markdown
Contributor Author

Validated against staging Cloud

Re-ran after opening, against a real LiveKit Cloud staging deployment (wss://xianstaging-hixkk74p.staging.livekit.cloud) rather than only localhost:

PeerConnectionSignalingTests — 9/9 passed, both PC modes (50.2s)
  connect · twoParticipants · audioTrack · reconnect · dataChannel
  fullReconnect · publishManyTracks · doubleReconnect · v1LocalhostFallback

This exercises the base64url fix on a real Cloud SFU, which is the confirmation the description flagged as outstanding — the encoding concern was verified in Foundation but not end to end at the time of opening.

Note the gzip half still makes no difference on the wire by itself: a minimal join request is smaller than gzip's framing, so it correctly stays uncompressed (covered by smallRequestIsNotCompressed). It starts paying off with #1111 stacked on top, where the bundled SDP makes the payload multi-KB.

Run with:

TEST_RUNNER_LIVEKIT_TESTING_URL=wss://…  TEST_RUNNER_LIVEKIT_TESTING_API_KEY=… \
TEST_RUNNER_LIVEKIT_TESTING_API_SECRET=… \
xcodebuild test -scheme LiveKit -only-testing LiveKitCoreTests/PeerConnectionSignalingTests -destination 'platform=macOS'

(the TEST_RUNNER_ prefix is required — xcodebuild does not forward the shell environment to the test process)

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.

2 participants