diff --git a/.changes/rpc-error-truncation-byte-length b/.changes/rpc-error-truncation-byte-length new file mode 100644 index 000000000..0f187a9a0 --- /dev/null +++ b/.changes/rpc-error-truncation-byte-length @@ -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" diff --git a/lib/src/rpc/rpc_server_manager.dart b/lib/src/rpc/rpc_server_manager.dart index d7eca716f..b317253e2 100644 --- a/lib/src/rpc/rpc_server_manager.dart +++ b/lib/src/rpc/rpc_server_manager.dart @@ -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 @@ -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 || diff --git a/lib/src/rpc/rpc_utils.dart b/lib/src/rpc/rpc_utils.dart new file mode 100644 index 000000000..0fccc9c57 --- /dev/null +++ b/lib/src/rpc/rpc_utils.dart @@ -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) { + 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; diff --git a/test/core/rpc_test.dart b/test/core/rpc_test.dart index fe30eda0b..255b8cd0b 100644 --- a/test/core/rpc_test.dart +++ b/test/core/rpc_test.dart @@ -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'; @@ -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 {