Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/rpc-error-truncation-byte-length
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "RPC error `message` and `data` are now truncated by UTF-8 byte length rather than by UTF-16 code unit count, so the 15 KB cap actually bounds the packet and a truncated field no longer ends in half a surrogate pair"
9 changes: 3 additions & 6 deletions lib/src/rpc/rpc_server_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import '../proto/livekit_models.pb.dart' as lk_models;
import '../types/data_stream.dart';
import '../types/other.dart';
import '../types/rpc.dart';
import 'rpc_utils.dart';

/// Handler-side RPC dispatcher. Owns the registered-method map and routes incoming
/// requests (v1 packets and v2 data streams) to user handlers, choosing the response
Expand Down Expand Up @@ -274,15 +275,11 @@ class RpcServerManager {
final originalMessage = error.message;
final originalData = error.data;

final truncatedMessage = originalMessage.length > kRpcMaxPayloadBytes
? originalMessage.substring(0, kRpcMaxPayloadBytes)
: originalMessage;
final truncatedMessage = truncateBytes(originalMessage, kRpcMaxPayloadBytes);

String? truncatedData;
if (originalData != null) {
truncatedData = originalData.length > kRpcMaxPayloadBytes
? originalData.substring(0, kRpcMaxPayloadBytes)
: originalData;
truncatedData = truncateBytes(originalData, kRpcMaxPayloadBytes);
}

if (truncatedMessage.length != originalMessage.length ||
Expand Down
56 changes: 56 additions & 0 deletions lib/src/rpc/rpc_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'dart:convert';

import 'package:meta/meta.dart';

/// The number of bytes [s] occupies once encoded as UTF-8.
@internal
int byteLength(String s) => utf8.encode(s).length;

/// Truncates [s] so that its UTF-8 encoding fits within [maxBytes].
///
/// Dart strings are counted in UTF-16 code units, which is not the unit the wire
/// budget is expressed in, so the cut point is found by binary search over the
/// encoded length rather than by slicing at [maxBytes] directly.
@internal
String truncateBytes(String s, int maxBytes) {
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
if (byteLength(s) <= maxBytes) {
return s;
}

var low = 0;
var high = s.length;

// Binary search for the longest prefix that fits.
while (low < high) {
final mid = (low + high + 1) ~/ 2;
if (byteLength(s.substring(0, mid)) <= maxBytes) {
low = mid;
} else {
high = mid - 1;
}
}

// Never end on the first half of a surrogate pair: it would be encoded as a
// replacement character, and the truncated string would no longer round trip.
if (low > 0 && _isHighSurrogate(s.codeUnitAt(low - 1))) {
low -= 1;
}

return s.substring(0, low);
}

bool _isHighSurrogate(int codeUnit) => codeUnit >= 0xd800 && codeUnit <= 0xdbff;
49 changes: 49 additions & 0 deletions test/core/rpc_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
@Timeout(Duration(seconds: 10))
library;

import 'dart:convert';

import 'package:flutter_test/flutter_test.dart';

import 'package:livekit_client/livekit_client.dart';
Expand Down Expand Up @@ -497,6 +499,53 @@ void main() {
v2Room.unregisterRpcMethod('throws-rpc-error');
});

test('v2 oversize error message is truncated on a character boundary', () async {
v2Container.capturedDataPackets.clear();

// 'a' sits at index 0, so every following surrogate pair starts on an odd
// index. Cutting at a code-unit count lands on a high surrogate and splits
// the pair in half.
final longMessage = 'a${'😀' * 8000}';
expect(longMessage.length, greaterThan(kRpcMaxPayloadBytes));

v2Room.registerRpcMethod('throws-huge-error', (_) async {
throw RpcError(code: 101, message: longMessage);
});

RpcError? error;
try {
await v2Room.localParticipant!.performRpc(
PerformRpcParams(
destinationIdentity: v2Room.localParticipant!.identity,
method: 'throws-huge-error',
payload: 'x',
),
);
} catch (e) {
if (e is RpcError) {
error = e;
}
}

expect(error?.code, 101);

final message = error!.message;
// A split surrogate pair cannot be encoded, so it reaches the peer as U+FFFD.
expect(
message.contains('�'),
isFalse,
reason: 'truncation split a surrogate pair',
);
// The cap exists to bound the wire packet, so it has to be counted in bytes.
expect(
utf8.encode(message).length,
lessThanOrEqualTo(kRpcMaxPayloadBytes),
reason: 'truncated message still exceeds the v1 payload budget',
);

v2Room.unregisterRpcMethod('throws-huge-error');
});

test('v2 response timeout', () async {
v2Container.capturedDataPackets.clear();
v2Room.registerRpcMethod('hangs', (_) async {
Expand Down
Loading