Skip to content

Commit f2b194f

Browse files
committed
docs: check in draft spec
1 parent 07ff61a commit f2b194f

1 file changed

Lines changed: 349 additions & 0 deletions

File tree

RPC_SPEC.md

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
# RPC v2 Specification
2+
3+
## Overview
4+
5+
RPC (Remote Procedure Call) allows participants in a LiveKit room to invoke methods on each other
6+
and receive responses. RPC v1 used inline protobuf packets (`RpcRequest` / `RpcResponse`) with a
7+
hard 15 KB payload limit.
8+
9+
RPC v2 lifts this limit by transporting request and response payloads over **data streams**, while
10+
retaining v1 as a fallback for legacy clients. This removes the previously set 15kb request /
11+
response payload size limitation, making both effectively limitless.
12+
13+
A v2 client should seamlessly communicate with v1 clients by detecting the remote participant's
14+
client protocol version and falling back to v1 packets if it doesn't support rpc v2.
15+
16+
---
17+
18+
## Part 1: Client protocol
19+
20+
### What is client protocol?
21+
22+
`clientProtocol` is a new integer that each participant advertises in their `ParticipantInfo` via
23+
the LiveKit signaling channel. It tells other participants what client-to-client features this SDK
24+
supports. It is distinct from the existing `protocol` field (which tracks signaling protocol
25+
version) - `clientProtocol` specifically governs peer-to-peer feature negotiation.
26+
27+
| Value | Constant name | Meaning |
28+
|-------|---------------|---------|
29+
| `0` | `CLIENT_PROTOCOL_DEFAULT` | Legacy client. Only supports RPC v1. |
30+
| `1` | `CLIENT_PROTOCOL_DATA_STREAM_RPC` | Supports RPC v2 (data stream-based payloads). |
31+
32+
### What SDKs need to do
33+
34+
1. **Advertise**: Set your SDK's `clientProtocol` to `1` (`CLIENT_PROTOCOL_DATA_STREAM_RPC`) in the
35+
`ParticipantInfo` sent during the join handshake.
36+
37+
2. **Read**: When a remote participant joins or updates, store their `clientProtocol` value. This is
38+
available on the `ParticipantInfo` protobuf. If the field is absent or unrecognized, treat it as
39+
`0` (`CLIENT_PROTOCOL_DEFAULT`).
40+
41+
3. **Use**: Before sending an RPC request or response, look up the remote participant's
42+
`clientProtocol` to decide whether to use the v1 (packet) or v2 (data stream) transport.
43+
44+
---
45+
46+
## Part 2: RPC protocol updates
47+
48+
As a review, here is how RPC v1 works today:
49+
50+
```
51+
Caller Handler
52+
| |
53+
|--- RpcRequest (DataPacket) ------->|
54+
| | (looks up handler, invokes it)
55+
|<-- RpcAck (DataPacket) -----------|
56+
|<-- RpcResponse (DataPacket) ------|
57+
| |
58+
```
59+
60+
1. The **caller** sends a `DataPacket` containing a `RpcRequest` protobuf:
61+
- `id`: a UUID identifying this request
62+
- `method`: the method name
63+
- `payload`: the request payload string (must be <= 15 KB)
64+
- `responseTimeoutMs`: the effective timeout in milliseconds
65+
- `version`: `1`
66+
- Packet kind: `RELIABLE`
67+
- Destination: the handler's identity
68+
69+
2. The **handler** receives the `RpcRequest` packet and immediately sends an **ack** - a
70+
`DataPacket` containing a `RpcAck` protobuf with the `requestId`. This tells the caller that
71+
the handler is alive and processing.
72+
73+
3. The handler invokes the registered method handler. When it completes, the handler sends a
74+
`DataPacket` containing a `RpcResponse` protobuf:
75+
- `requestId`: matches the original request
76+
- `value`: either `{ case: 'payload', value: responseString }` for success, or
77+
`{ case: 'error', value: RpcError protobuf }` for failure
78+
- The response payload is also subject to the 15 KB limit.
79+
80+
4. The **caller** receives the `RpcResponse` and resolves or rejects the pending promise.
81+
82+
### RPC v2 Example
83+
84+
v2 replaces the `RpcRequest` and `RpcResponse` protobuf packets with **text data streams** for
85+
carrying payloads. The ack mechanism is unchanged. This removes the payload size limit while
86+
remaining backward-compatible with v1 clients.
87+
88+
```
89+
Caller Handler
90+
| |
91+
|--- Text data stream (request) --->|
92+
| topic: "lk.rpc_request" |
93+
| attrs: request_id, method, |
94+
| timeout, version=2 |
95+
| body: <payload> |
96+
| | (reads stream, looks up handler, invokes it)
97+
|<-- RpcAck (DataPacket) -----------|
98+
|<-- Text data stream (response) ---|
99+
| topic: "lk.rpc_response" |
100+
| attrs: request_id |
101+
| body: <response payload> |
102+
| |
103+
```
104+
105+
1. The **caller** opens a text data stream with:
106+
- **Topic**: `lk.rpc_request`
107+
- **Destination identities**: `[destinationIdentity]`
108+
- **Attributes**:
109+
- `lk.rpc_request_id`: a newly generated UUID
110+
- `lk.rpc_request_method`: the method name
111+
- `lk.rpc_request_response_timeout_ms`: the effective timeout in milliseconds, as a string
112+
- `lk.rpc_request_version`: `"2"`
113+
- Writes the payload string to the stream, then closes it.
114+
115+
2. The **handler** receives the data stream on topic `lk.rpc_request`. It parses the attributes
116+
to extract the request ID, method, timeout, and version. It sends an **ack** (same `RpcAck`
117+
packet as v1), then reads the full stream payload.
118+
119+
3. The handler invokes the registered method handler. On success, it sends the response as a
120+
text data stream:
121+
- **Topic**: `lk.rpc_response`
122+
- **Destination identities**: `[callerIdentity]`
123+
- **Attributes**: `{ "lk.rpc_request_id": requestId }`
124+
- Writes the response payload, then closes the stream.
125+
126+
4. The **caller** receives the data stream on topic `lk.rpc_response`. It reads the
127+
`lk.rpc_request_id` attribute to match it to the pending request, reads the full stream,
128+
and resolves the pending promise with the payload.
129+
130+
The user-facing API should be identical for v1 and v2.
131+
132+
The protocol version negotiation is invisible to the user. The only visible difference that a user
133+
should see is that if they send a rpc request or receive a rpc response from a participant
134+
supporting rpc v2 with a length greater than 15kb, they will NOT receive a
135+
`REQUEST_PAYLOAD_TOO_LARGE` / `RESPONSE_PAYLOAD_TOO_LARGE` error - it will "just work". With rpc v2,
136+
these errors are effectively deprecated.
137+
138+
#### Error responses in v2
139+
140+
**Error responses are always sent as v1 `RpcResponse` packets**, even when both sides are v2. This
141+
is because error payloads tend to be small (code + message + optional data) and using packets keeps
142+
the error path simple and uniform. This means:
143+
144+
- Success responses between two v2 clients: **data stream**
145+
- Error responses between two v2 clients: **packet** (`RpcResponse` with `error` case)
146+
- All responses to v1 clients: **packet**
147+
148+
#### Data stream topic routing
149+
150+
RPC requests and responses use separate data stream topics:
151+
152+
- **`lk.rpc_request`**: Register a text stream handler for this topic. Incoming streams are RPC
153+
requests. Route to the handler-side logic, passing the sender identity and the stream attributes.
154+
- **`lk.rpc_response`**: Register a text stream handler for this topic. Incoming streams are RPC
155+
responses. Read the `lk.rpc_request_id` attribute to match the response to a pending request,
156+
then route to the caller-side logic.
157+
158+
### Version negotiation and backward compatibility
159+
160+
The transport used for a given RPC call depends on what both sides support. The caller decides the
161+
request transport; the handler decides the response transport.
162+
163+
| Caller | Handler | Request transport | Response transport |
164+
|--------|---------|------------------|--------------------|
165+
| v2 | v2 | Data stream | Data stream (success) / Packet (error) |
166+
| v2 | v1 | Packet (`RpcRequest`) | Packet (`RpcResponse`) |
167+
| v1 | v2 | Packet (`RpcRequest`) | Packet (`RpcResponse`) |
168+
| v1 | v1 | Packet (`RpcRequest`) | Packet (`RpcResponse`) |
169+
170+
**Data streams are only used when both sides are v2.** Cross-version interactions always fall back
171+
to v1 packets. This is because:
172+
173+
- The **caller** checks the remote participant's `clientProtocol` before sending. If the remote is
174+
v1, the caller sends a v1 `RpcRequest` packet.
175+
- The **handler** checks the caller's `clientProtocol` before responding. If the caller is v1, the
176+
handler sends a v1 `RpcResponse` packet. (The handler knows the caller is v1 because the request
177+
arrived as a v1 packet, and it can also check the caller's `clientProtocol`.)
178+
179+
### Timeout and ack behavior
180+
181+
These behaviors are the same for v1 and v2.
182+
183+
## Minimum required test cases
184+
185+
The following tests represent the minimum set that must pass for a conforming implementation. They
186+
are organized by the version interaction being tested. Since this spec describes implementing a v2
187+
SDK, at least one side of every interaction is always v2. Each test describes the full lifecycle
188+
from both the caller and handler perspectives.
189+
190+
### v2 -> v2 (both sides support data streams)
191+
192+
1. **Caller happy path (short payload)**
193+
- The caller opens a text data stream on topic `lk.rpc_request` with attributes
194+
`lk.rpc_request_id`, `lk.rpc_request_method`, `lk.rpc_request_response_timeout_ms`, and
195+
`lk.rpc_request_version: "2"`.
196+
- The caller writes the payload string to the stream and closes it.
197+
- Verify no `RpcRequest` packet is produced.
198+
- Simulate the handler sending a `RpcAck` packet and a successful response.
199+
- Verify the caller resolves with the response payload.
200+
201+
2. **Caller happy path (large payload > 15 KB)**
202+
- The caller opens a text data stream on topic `lk.rpc_request` with the same attributes as
203+
above, but with a payload exceeding 15 KB (e.g., 20,000 bytes).
204+
- The caller writes the large payload to the stream and closes it.
205+
- Verify no `REQUEST_PAYLOAD_TOO_LARGE` error is raised - the data stream path has no size
206+
limit.
207+
- Simulate the handler sending a `RpcAck` packet and a successful response.
208+
- Verify the caller resolves with the response payload.
209+
210+
3. **Handler happy path**
211+
- The handler receives a text data stream on topic `lk.rpc_request` with valid attributes.
212+
- The handler sends a `RpcAck` packet with the request ID.
213+
- The handler reads the full stream payload and invokes the registered method handler with
214+
`{ requestId, callerIdentity, payload, responseTimeout }`.
215+
- The method handler returns a response string.
216+
- The handler sends the response as a text data stream on topic `lk.rpc_response` with
217+
attribute `lk.rpc_request_id` set to the request ID.
218+
- Verify no `RpcResponse` packet is produced - successful v2 responses use data streams.
219+
220+
4. **Unhandled error in handler**
221+
- The handler receives a v2 data stream request.
222+
- The handler sends a `RpcAck` packet.
223+
- The registered method handler throws a non-RpcError exception (e.g., a generic `Error`).
224+
- The handler sends a `RpcResponse` **packet** (not a data stream) with error code
225+
`APPLICATION_ERROR` (1500).
226+
- Verify error responses always use packets, even between two v2 clients.
227+
228+
5. **RpcError passthrough in handler**
229+
- The handler receives a v2 data stream request.
230+
- The handler sends a `RpcAck` packet.
231+
- The registered method handler throws a `RpcError` with a custom code (e.g., 101) and
232+
message.
233+
- The handler sends a `RpcResponse` packet preserving the original error code and message.
234+
235+
6. **Response timeout**
236+
- The caller sends a v2 data stream request with a short response timeout (e.g., 50ms).
237+
- No `RpcAck` or response arrives.
238+
- After the timeout elapses, the caller rejects with `RESPONSE_TIMEOUT` (code 1502).
239+
240+
7. **Error response**
241+
- The caller sends a v2 data stream request.
242+
- Simulate the handler sending a `RpcAck` packet, then a `RpcResponse` packet with an error
243+
(e.g., code 101, message "Test error message").
244+
- Verify the caller rejects with the correct error code and message.
245+
246+
8. **Participant disconnection**
247+
- The caller sends a v2 data stream request.
248+
- Before any ack or response arrives, the remote participant disconnects.
249+
- Verify the caller rejects with `RECIPIENT_DISCONNECTED` (code 1503).
250+
251+
### v2 -> v1 (v2 caller, v1 handler)
252+
253+
10. **Caller happy path (request fallback)**
254+
- The caller detects the remote's `clientProtocol` is 0.
255+
- The caller sends a v1 `RpcRequest` packet (not a data stream) with correct `id`, `method`,
256+
`payload`, `responseTimeoutMs`, and `version: 1`.
257+
- Verify no data stream is opened.
258+
- Simulate the handler sending a `RpcAck` packet, then a `RpcResponse` packet with a
259+
success payload.
260+
- Verify the caller resolves with the response payload.
261+
262+
11. **Handler happy path (v1 request)**
263+
- The handler receives a v1 `RpcRequest` packet with `version: 1`.
264+
- The handler sends a `RpcAck` packet with the request ID.
265+
- The handler invokes the registered method handler with `{ requestId, callerIdentity,
266+
payload, responseTimeout }`.
267+
- The method handler returns a response string.
268+
- The handler detects the caller's `clientProtocol` is 0 and sends the response as a v1
269+
`RpcResponse` packet (not a data stream).
270+
271+
12. **Payload too large**
272+
- The caller detects the remote's `clientProtocol` is 0.
273+
- The caller attempts to send a payload exceeding 15 KB.
274+
- Verify it rejects immediately with `REQUEST_PAYLOAD_TOO_LARGE` (code 1402) without producing
275+
any packet or data stream.
276+
277+
13. **Response timeout**
278+
- The caller detects the remote's `clientProtocol` is 0.
279+
- The caller sends a v1 `RpcRequest` packet with a short response timeout (e.g., 50ms).
280+
- No `RpcAck` or response arrives.
281+
- After the timeout elapses, the caller rejects with `RESPONSE_TIMEOUT` (code 1502).
282+
283+
14. **Error response**
284+
- The caller detects the remote's `clientProtocol` is 0.
285+
- The caller sends a v1 `RpcRequest` packet.
286+
- Simulate the handler sending a `RpcAck` packet, then a `RpcResponse` packet with an
287+
error (e.g., code 101, message "Test error message").
288+
- Verify the caller rejects with the correct error code and message.
289+
290+
15. **Participant disconnection**
291+
- The caller detects the remote's `clientProtocol` is 0.
292+
- The caller sends a v1 `RpcRequest` packet.
293+
- Before any ack or response arrives, the remote participant disconnects.
294+
- Verify the caller rejects with `RECIPIENT_DISCONNECTED` (code 1503).
295+
296+
### v1 -> v2 (v1 caller, v2 handler)
297+
298+
16. **Handler happy path (response fallback)**
299+
- A v1 caller sends a v1 `RpcRequest` packet with `version: 1`.
300+
- The v2-capable handler receives it and sends a `RpcAck` packet.
301+
- The handler invokes the registered method handler, which returns a response string.
302+
- The handler detects the caller's `clientProtocol` is 0 and sends the response as a v1
303+
`RpcResponse` packet (not a data stream), even though it supports v2.
304+
- Verify no data stream is opened for the response.
305+
306+
17. **Unhandled error in handler (v1 caller)**
307+
- A v1 caller sends a v1 `RpcRequest` packet.
308+
- The handler sends a `RpcAck` packet.
309+
- The registered method handler throws a non-RpcError exception (e.g., a generic `Error`).
310+
- The handler sends a `RpcResponse` packet with error code `APPLICATION_ERROR` (1500).
311+
312+
18. **RpcError passthrough (v1 caller)**
313+
- A v1 caller sends a v1 `RpcRequest` packet.
314+
- The handler sends a `RpcAck` packet.
315+
- The registered method handler throws a `RpcError` with a custom code (e.g., 101) and
316+
message.
317+
- The handler sends a `RpcResponse` packet preserving the original error code and message.
318+
319+
---
320+
321+
## Benchmarking
322+
323+
Implementing a benchmark is not required, but could be useful for validating correctness and
324+
performance. The below outlines the test criteria used for `client-sdk-cpp` and `client-sdk-js`.
325+
326+
For an exact reference implementation, see https://github.com/livekit/client-sdk-js/commit/da26fa022197326a8f31db5421f175fad2fe4651.
327+
328+
### Approach
329+
330+
The benchmark connects two participants to the same room in a single process:
331+
332+
1. **Setup**: A "caller" and "receiver" join the same room.
333+
2. **Echo handler**: The receiver registers an RPC method (`benchmark-echo`) that returns the
334+
received payload unchanged.
335+
3. **Payload**: Pre-generate a payload of the desired size. Compute a checksum (e.g., sum of
336+
character codes) for integrity verification.
337+
4. **Caller loop**: N concurrent workers each loop for a configured duration, calling the
338+
echo method and verifying the response matches the original payload (length + checksum).
339+
5. **Metrics**: Track success/failure counts, latency percentiles (p50, p95, p99), throughput
340+
(calls/sec), and error breakdown.
341+
342+
### Suggested parameters
343+
344+
| Parameter | Suggested default | Description |
345+
|-----------|-------------------|-------------|
346+
| Payload size | 15360 bytes | Size of the RPC payload in bytes |
347+
| Duration | 30 seconds | How long the benchmark runs |
348+
| Concurrency | 3 | Number of parallel caller loops |
349+
| Delay between calls | 10ms | Pause between consecutive calls per thread |

0 commit comments

Comments
 (0)