From 6e7726815eee47c37512f2b7a9063eacdede6083 Mon Sep 17 00:00:00 2001 From: Shijing Xian Date: Fri, 23 Oct 2026 14:56:19 -0700 Subject: [PATCH 1/2] Truncate RPC error fields by byte length, not code unit count _truncateForWire compared String.length against kRpcMaxPayloadBytes and sliced with substring. String.length counts UTF-16 code units, not bytes, so the cap did not bound the wire packet it exists to bound: a message of non-ASCII text could still encode to well over 15 KB. The same slice could also land between the halves of a surrogate pair, sending the pair's first half alone, which encodes as U+FFFD and reached the peer corrupted. Add truncateBytes, which binary searches for the longest prefix fitting the byte budget and steps back off a trailing high surrogate, and use it for both the message and the data field. Port of livekit/client-sdk-js#2126. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/rpc/rpc_server_manager.dart | 9 ++--- lib/src/rpc/rpc_utils.dart | 56 +++++++++++++++++++++++++++++ test/core/rpc_test.dart | 49 +++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 lib/src/rpc/rpc_utils.dart 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 { From 534e4b619787252cdb2d040ff987023010ffe838 Mon Sep 17 00:00:00 2001 From: Shijing Xian Date: Fri, 23 Oct 2026 16:57:57 -0700 Subject: [PATCH 2/2] Add the release entry for the RPC truncation fix Co-Authored-By: Claude Opus 5 (1M context) --- .changes/rpc-error-truncation-byte-length | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/rpc-error-truncation-byte-length 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"