signalling: add compression envelopes for signal messages - #1746
Draft
xianshijing-lk wants to merge 10 commits into
Draft
signalling: add compression envelopes for signal messages#1746xianshijing-lk wants to merge 10 commits into
xianshijing-lk wants to merge 10 commits into
Conversation
`WrappedJoinRequest` already compresses the join payload carried in the connect URL, but every message after the handshake goes over the WebSocket uncompressed. On the resume path that is where the bulk of the bytes are: `SyncState` alone carries two full session descriptions plus the subscription, publish and data channel lists, and the publisher ICE-restart offer follows right behind it. Measured against a minimal one-audio/one-video offer, an SDP is 5734 bytes raw and 1515 gzipped -- a 3.8x saving on the single largest repeated payload. Signalling is one TCP connection, so these are not merely large, they are in the way: a multi-kilobyte offer in flight head-of-line blocks everything queued behind it, including the trickle candidates the PeerConnection needs to finish its ICE restart. Compressing them shortens recovery on exactly the degraded networks where recovery matters. `permessage-deflate` would have avoided a protocol change -- livekit-server already sets `EnableCompression: true` on the client-facing upgrader -- but no usable Rust WebSocket crate implements it, and it would not reach SDKs whose host application supplies its own WebSocket transport. Doing it at the application layer covers every transport uniformly, and mirrors what the SFU already does gzipping signal messages on its own internal transport. Negotiation reuses the existing `CAP_COMPRESSION_DEFLATE_RAW` capability, which clients already advertise and nothing currently reads. The server acknowledges it in `JoinResponse`/`ReconnectResponse`; unset means the current uncompressed format, so old and new peers on either side interoperate unchanged. Those two responses are themselves unwrapped, since they are what establish the agreement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 6febbf7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Replaces the top-level `SignalCompression` enum with a holder message carrying a nested `Type`. Both envelopes now reference `SignalCompression.Type`. The top-level form forced prefixed value names: enum values follow C++ scoping and are siblings of their type, so a bare `NONE` collides with the one already declared in livekit_sip.proto -- verified against protoc, which rejects it outright. Nesting scopes the values, so `NONE`/`GZIP`/`DEFLATE_RAW` read the same way as every other enum in this file. Nesting an enum inside each envelope, mirroring WrappedJoinRequest exactly, would also have avoided the prefix, but at the cost of two structurally identical yet type-incompatible enums. Compression is naturally generic over direction -- the SFU's own internal implementation threads a single compression type through both the request and response paths -- and splitting the type would force conversion code on every implementation for no gain. Value numbers match WrappedJoinRequest.Compression rather than ranking the options; two enums in one file where GZIP has different numbers invites mistakes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…flag Replaces `JoinResponse.signal_compression` and `ReconnectResponse.signal_compression` with a `SignalCompressionAck` arm on the `SignalResponse` oneof, sent unwrapped as the first message on the connection. The flags could not cover the message carrying them. A client has to know whether to parse the first frame as `SignalResponse` or `WrappedSignalResponse` before it can read anything out of it, so a flag inside the JoinResponse necessarily left that JoinResponse uncompressed -- and on a large room the join roster is the single biggest message on the wire, scaling with participant count and unbounded through participant metadata and attributes. Exactly the case worth compressing. A separate first frame removes the circularity without any sniffing. Both possibilities for frame one are a plain `SignalResponse`, so the client parses one type and switches on the arm it receives: `compression_ack` means compress from here on, anything else means an older server and the connection carries on uncompressed. A client that never advertised the capability is never sent the ack. All four old/new combinations keep working, and there is no extra round trip -- the server writes the ack and the JoinResponse back to back. Collapsing the two per-response booleans into one mechanism also drops the question of what a resume inherits: the ack is exchanged per connection, so a resume landing on a different node simply negotiates again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces WrappedSignalRequest/WrappedSignalResponse and the SignalCompressionAck handshake with a `compressed` arm on the SignalRequest and SignalResponse oneofs. The envelope form needed a negotiation handshake for one reason: a receiver had to know whether to parse a frame as SignalResponse or WrappedSignalResponse before it could read anything out of it. That forced an agreement to be established first, and whatever message established it could not itself be compressed. As an arm of the oneof, the tag is the discriminator. The receiver always parses a SignalResponse and switches on the arm it got, so nothing needs to be agreed in advance and the very first message can be compressed -- including JoinResponse, which in a large room is the biggest message on the wire and the one this is most worth doing for. That removes the extra frame, the ack message, and the two per-response booleans that preceded it. It is also cheaper on the wire. Under the envelope, once compression was on every message paid envelope overhead, including messages too small to be worth compressing and sent with compression NONE. Here a small message is sent as its ordinary arm and costs nothing, so the threshold has no downside. Gating is unchanged: the arm is used only against a peer that advertised CAP_COMPRESSION_DEFLATE_RAW, since an older peer would see an unknown field and silently drop the message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uests The capability gate only covered one direction. ClientInfo.CAP_COMPRESSION_DEFLATE_RAW tells the server what the client can decode, so server-to-server compression was safe. Nothing told the client whether the server could decode a compressed SignalRequest. Guessing wrong fails in the worst possible way. An older server parses the unknown field into its unknown-field set and leaves the oneof unset; the switch in livekit-server's signalhandler has no default case, so the message is dropped with no error and no warning. A resume would stall on a SyncState that never arrived rather than failing and escalating. Adds accepts_compressed_signal to JoinResponse and ReconnectResponse. This is not the circular arrangement removed earlier: that flag had to be read before the client could parse the message carrying it, whereas this one gates only what the client SENDS. Parsing is still decided by the oneof tag alone, so the JoinResponse carrying the flag can itself be compressed. ReconnectResponse repeats it because a resume may land on a different node, and because the largest client-to-server message of all -- SyncState, carrying two session descriptions plus the subscription and publish lists -- is sent immediately after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proposal
Let signal messages on the WebSocket be compressed, the way
WrappedJoinRequestalready compresses the join payload in the connect URL. Adds acompressedarm to theSignalRequest/SignalResponseoneofs, carryingCompressedSignalRequest/CompressedSignalResponseand a sharedSignalCompression.Typeenum.No negotiation handshake, no extra frames, no version gate. The receiver always parses a
SignalRequest/SignalResponse; the oneof tag says whether the payload is compressed.Draft — the proto compiles, but I'd like agreement on the shape before anyone implements against it.
Why
Everything after the handshake goes over the socket uncompressed. On the resume path that's where the bulk of the bytes are:
SyncStateSessionDescriptions + subscription / publish / data-channel listsOffer(publisher ICE restart)Offer/Answer(subscriber)JoinResponse.other_participantsParticipantInfo, unbounded via metadata/attributesMeasured against a real minimal one-audio/one-video offer: 5,734 bytes raw → 1,515 gzipped (3.8×).
Size isn't the only cost. Signalling is a single TCP connection, so a multi-kilobyte offer in flight head-of-line blocks everything queued behind it — including the trickle candidates the PeerConnection needs to finish its ICE restart. Compressing these directly shortens recovery on the degraded networks where recovery matters most.
How it works
Receiver parses
SignalResponse. If it lands oncompressed, it inflatessignal_responseand parses that as aSignalResponse. One level only; senders must not nest.Senders use the arm only against a peer that advertised
ClientInfo.CAP_COMPRESSION_DEFLATE_RAW— which clients already send today and nothing currently reads inlivekit-serverorcloud. An older peer would see an unknown field and silently drop the message, so the capability gate is load-bearing.Below roughly 200 bytes, senders just use the ordinary arm — matching the threshold
cloud/pkg/rtc/signal/compress.go:14already uses internally.Why this shape
Two earlier revisions in this branch, both worse:
JoinResponse. A receiver has to know whether a frame is aSignalResponseor aWrappedSignalResponsebefore reading anything out of it, so the agreement had to be established first — and the message establishing it couldn't itself be compressed. That permanently excluded the join roster, the biggest message in a large room.SignalCompressionAckfirst frame. Fixed the above (no extra RTT — the server writes the ack and the JoinResponse back to back), but cost an extra message type and an extra frame.As a oneof arm, the tag is the discriminator, so neither is needed. It's also cheaper on the wire: under an envelope, every message pays envelope overhead once compression is on, including small ones sent with
compression = NONE. Here a small message is its ordinary arm and costs nothing, so the threshold has no downside.Why not
permessage-deflateIt would need no protocol change, and
livekit-serveralready setsEnableCompression: trueon the client-facing upgrader (pkg/service/rtcservice.go:76-77). But:tungstenite0.29 (current),fastwebsockets0.10,tokio-websockets0.13 — none. Onlysokettodoes, and it's a poor structural fit.livekit_net::set_ws_client). Each host would have to negotiate it independently.Application-layer compression covers every transport uniformly, and mirrors what the SFU already does gzipping
SignalRequest/SignalResponseon its own node-to-node transport.Shape of the enum
SignalCompressionis a holder message with a nestedType, rather than a top-level enum or an enum nested in each message:NONEcollides with the one already inlivekit_sip.proto— protoc rejects it outright.Values are numbered to match
WrappedJoinRequest.Compressionrather than to rank the options; two enums in one file whereGZIPhas different numbers invites mistakes.Open questions
DEFLATE_RAWvsGZIPas the recommended default. Raw deflate saves gzip's ~18 bytes of header/trailer per message, which matters at a 200-byte threshold. BothWrappedJoinRequestand the SFU's internal path use gzip today. Recommendation, or mandate one?CAP_COMPRESSION_DEFLATE_RAWseemed right since it exists, is already advertised, and is read by nothing. But it was presumably minted for data streams — is overloading it acceptable, or would you rather have a distinct capability?Notes
protocvalidates clean; Go codegen committed by theGenerateworkflow.🤖 Generated with Claude Code