From 4461d2f1a74aedc41d9f339abfba54640610a5de Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 15 May 2026 11:01:08 -0400 Subject: [PATCH 1/8] feat: add LLM generated rpc tester This can be used to exercise sending / receiving rpc requests in the example app --- example/lib/widgets/controls.dart | 13 + example/lib/widgets/rpc_test_sheet.dart | 631 ++++++++++++++++++++++++ 2 files changed, 644 insertions(+) create mode 100644 example/lib/widgets/rpc_test_sheet.dart diff --git a/example/lib/widgets/controls.dart b/example/lib/widgets/controls.dart index 3c96b4ebc..74d1a4008 100644 --- a/example/lib/widgets/controls.dart +++ b/example/lib/widgets/controls.dart @@ -10,6 +10,7 @@ import 'package:livekit_client/livekit_client.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../exts.dart'; +import 'rpc_test_sheet.dart'; class ControlsWidget extends StatefulWidget { // @@ -38,6 +39,8 @@ class _ControlsWidgetState extends State { bool _speakerphoneOn = Hardware.instance.speakerOn ?? false; + final _rpcController = RpcTestController(); + @override void initState() { super.initState(); @@ -52,6 +55,7 @@ class _ControlsWidgetState extends State { void dispose() { unawaited(_subscription?.cancel()); participant.removeListener(_onChange); + _rpcController.dispose(); super.dispose(); } @@ -459,8 +463,17 @@ class _ControlsWidgetState extends State { icon: const Icon(Icons.bug_report), tooltip: 'Simulate scenario', ), + IconButton( + onPressed: _onTapRpcTest, + icon: const Icon(Icons.swap_horiz), + tooltip: 'RPC test', + ), ], ), ); } + + void _onTapRpcTest() { + unawaited(showRpcTestSheet(context, widget.room, _rpcController)); + } } diff --git a/example/lib/widgets/rpc_test_sheet.dart b/example/lib/widgets/rpc_test_sheet.dart new file mode 100644 index 000000000..927fee5b0 --- /dev/null +++ b/example/lib/widgets/rpc_test_sheet.dart @@ -0,0 +1,631 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'package:livekit_client/livekit_client.dart'; + +class RpcInvocationRecord { + final DateTime timestamp; + final int byteLength; + final String payload; + final String callerIdentity; + + RpcInvocationRecord({ + required this.timestamp, + required this.byteLength, + required this.payload, + required this.callerIdentity, + }); +} + +class RpcHandlerEntry { + final String topic; + String staticResponse; + final List invocations = []; + + RpcHandlerEntry({required this.topic, required this.staticResponse}); +} + +class RpcTestController extends ChangeNotifier { + final List _handlers = []; + Room? _room; + + List get handlers => List.unmodifiable(_handlers); + + bool isRegistered(String topic) => _handlers.any((h) => h.topic == topic); + + void registerHandler(Room room, String topic, String staticResponse) { + if (isRegistered(topic)) return; + _room = room; + final entry = RpcHandlerEntry(topic: topic, staticResponse: staticResponse); + _handlers.add(entry); + room.registerRpcMethod(topic, (data) async { + final bytes = utf8.encode(data.payload).length; + entry.invocations.insert( + 0, + RpcInvocationRecord( + timestamp: DateTime.now(), + byteLength: bytes, + payload: data.payload, + callerIdentity: data.callerIdentity, + ), + ); + notifyListeners(); + return entry.staticResponse; + }); + notifyListeners(); + } + + void unregisterHandler(String topic) { + final idx = _handlers.indexWhere((h) => h.topic == topic); + if (idx < 0) return; + _room?.unregisterRpcMethod(topic); + _handlers.removeAt(idx); + notifyListeners(); + } + + // Mutates the entry's response in place. The handler closure reads + // entry.staticResponse fresh on each invocation, so no re-registration + // is needed. Skip notifyListeners to avoid rebuild loops with the + // editing TextField that drives this method. + void updateStaticResponse(String topic, String response) { + final entry = _handlers.firstWhere( + (h) => h.topic == topic, + orElse: () => throw StateError('topic $topic not registered'), + ); + entry.staticResponse = response; + } + + @override + void dispose() { + final room = _room; + if (room != null) { + for (final entry in _handlers) { + room.unregisterRpcMethod(entry.topic); + } + } + _handlers.clear(); + super.dispose(); + } +} + +final Map _payloadPresets = { + 'hello': () => 'hello world', + '20k': () => 'X' * 20000, +}; + +Future showRpcTestSheet( + BuildContext context, + Room room, + RpcTestController controller, +) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).cardColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + builder: (_) => RpcTestSheet(room: room, controller: controller), + ); + +class RpcTestSheet extends StatefulWidget { + final Room room; + final RpcTestController controller; + + const RpcTestSheet({ + required this.room, + required this.controller, + super.key, + }); + + @override + State createState() => _RpcTestSheetState(); +} + +class _RpcTestSheetState extends State { + final _methodCtl = TextEditingController(); + final _payloadCtl = TextEditingController(); + final _handlerTopicCtl = TextEditingController(); + final _handlerResponseCtl = TextEditingController(); + + String? _selectedIdentity; + bool _isSending = false; + _SendResult? _lastResult; + + @override + void dispose() { + _methodCtl.dispose(); + _payloadCtl.dispose(); + _handlerTopicCtl.dispose(); + _handlerResponseCtl.dispose(); + super.dispose(); + } + + Future _send() async { + final identity = _selectedIdentity; + final method = _methodCtl.text.trim(); + final local = widget.room.localParticipant; + if (identity == null || method.isEmpty || local == null) return; + final payload = _payloadCtl.text; + setState(() { + _isSending = true; + _lastResult = null; + }); + final stopwatch = Stopwatch()..start(); + try { + final response = await local.performRpc( + PerformRpcParams( + destinationIdentity: identity, + method: method, + payload: payload, + ), + ); + stopwatch.stop(); + if (!mounted) return; + setState(() { + _lastResult = _SendResult.success(response: response, elapsed: stopwatch.elapsed); + }); + } catch (e) { + stopwatch.stop(); + if (!mounted) return; + setState(() { + _lastResult = _SendResult.error(error: e, elapsed: stopwatch.elapsed); + }); + } finally { + if (mounted) { + setState(() { + _isSending = false; + }); + } + } + } + + void _register() { + final topic = _handlerTopicCtl.text.trim(); + if (topic.isEmpty) return; + if (widget.controller.isRegistered(topic)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Handler for "$topic" is already registered')), + ); + return; + } + widget.controller.registerHandler(widget.room, topic, _handlerResponseCtl.text); + _handlerTopicCtl.clear(); + } + + void _applyPreset(String preset, TextEditingController target) { + final fn = _payloadPresets[preset]; + if (fn == null) return; + target.text = fn(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(theme), + Flexible( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + children: [ + _buildSendSection(theme), + const SizedBox(height: 24), + const Divider(height: 1), + const SizedBox(height: 16), + _buildHandlersSection(theme), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHeader(ThemeData theme) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), + child: Row( + children: [ + Text('RPC Tester', style: theme.textTheme.titleLarge), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + + Widget _buildSendSection(ThemeData theme) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Send RPC', style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + ListenableBuilder( + listenable: widget.room, + builder: (context, _) { + final remotes = widget.room.remoteParticipants.values.toList(); + final identities = remotes.map((p) => p.identity).toList(); + final currentValue = identities.contains(_selectedIdentity) ? _selectedIdentity : null; + return DropdownButtonFormField( + initialValue: currentValue, + decoration: const InputDecoration( + labelText: 'Destination', + border: OutlineInputBorder(), + isDense: true, + ), + hint: Text(remotes.isEmpty ? 'no other participants' : 'select participant'), + items: remotes + .map((p) => DropdownMenuItem( + value: p.identity, + child: Text( + p.name.isNotEmpty ? '${p.name} (${p.identity})' : p.identity, + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: remotes.isEmpty + ? null + : (value) => setState(() { + _selectedIdentity = value; + }), + ); + }, + ), + const SizedBox(height: 12), + TextField( + controller: _methodCtl, + decoration: const InputDecoration( + labelText: 'Method', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _payloadCtl, + minLines: 3, + maxLines: 6, + decoration: const InputDecoration( + labelText: 'Payload', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + ..._payloadPresets.keys.map( + (preset) => Padding( + padding: const EdgeInsets.only(right: 8), + child: OutlinedButton( + onPressed: () => _applyPreset(preset, _payloadCtl), + child: Text(preset), + ), + ), + ), + const Spacer(), + ElevatedButton.icon( + onPressed: _canSend() ? () => unawaited(_send()) : null, + icon: _isSending + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + label: Text(_isSending ? 'Sending…' : 'Send'), + ), + ], + ), + if (_lastResult != null) ...[ + const SizedBox(height: 12), + _ResultPanel(result: _lastResult!), + ], + ], + ); + + bool _canSend() => + !_isSending && + _selectedIdentity != null && + _methodCtl.text.trim().isNotEmpty && + widget.room.localParticipant != null; + + Widget _buildHandlersSection(ThemeData theme) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Handlers', style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + TextField( + controller: _handlerTopicCtl, + decoration: const InputDecoration( + labelText: 'Topic', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _handlerResponseCtl, + minLines: 2, + maxLines: 5, + decoration: const InputDecoration( + labelText: 'Static response', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + ..._payloadPresets.keys.map( + (preset) => Padding( + padding: const EdgeInsets.only(right: 8), + child: OutlinedButton( + onPressed: () => _applyPreset(preset, _handlerResponseCtl), + child: Text(preset), + ), + ), + ), + const Spacer(), + ElevatedButton.icon( + onPressed: _register, + icon: const Icon(Icons.add), + label: const Text('Register'), + ), + ], + ), + const SizedBox(height: 16), + ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final handlers = widget.controller.handlers; + if (handlers.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text( + 'no handlers registered', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.disabledColor), + ), + ), + ); + } + return Column( + children: handlers + .map((e) => _HandlerCard( + key: ValueKey(e.topic), + entry: e, + controller: widget.controller, + )) + .toList(), + ); + }, + ), + ], + ); +} + +class _HandlerCard extends StatefulWidget { + final RpcHandlerEntry entry; + final RpcTestController controller; + + const _HandlerCard({required this.entry, required this.controller, super.key}); + + @override + State<_HandlerCard> createState() => _HandlerCardState(); +} + +class _HandlerCardState extends State<_HandlerCard> { + late final TextEditingController _responseCtl; + + @override + void initState() { + super.initState(); + _responseCtl = TextEditingController(text: widget.entry.staticResponse); + } + + @override + void dispose() { + _responseCtl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final entry = widget.entry; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + entry.topic, + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + TextButton.icon( + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Unregister'), + onPressed: () => widget.controller.unregisterHandler(entry.topic), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _responseCtl, + minLines: 1, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Static response', + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: (value) => widget.controller.updateStaticResponse(entry.topic, value), + ), + const SizedBox(height: 8), + Row( + children: [ + ..._payloadPresets.keys.map( + (preset) => Padding( + padding: const EdgeInsets.only(right: 8), + child: OutlinedButton( + onPressed: () { + final fn = _payloadPresets[preset]; + if (fn == null) return; + final value = fn(); + _responseCtl.text = value; + widget.controller.updateStaticResponse(entry.topic, value); + }, + child: Text(preset), + ), + ), + ), + ], + ), + const Divider(height: 24), + Text( + 'Invocations (${entry.invocations.length})', + style: theme.textTheme.labelMedium?.copyWith(color: theme.hintColor), + ), + const SizedBox(height: 4), + SizedBox( + height: 200, + child: entry.invocations.isEmpty + ? Center( + child: Text( + 'waiting…', + style: theme.textTheme.bodySmall?.copyWith(color: theme.disabledColor), + ), + ) + : ListView.separated( + itemCount: entry.invocations.length, + separatorBuilder: (_, __) => const Divider(height: 12), + itemBuilder: (_, i) => _InvocationRow(record: entry.invocations[i]), + ), + ), + ], + ), + ), + ); + } +} + +class _InvocationRow extends StatelessWidget { + final RpcInvocationRecord record; + + const _InvocationRow({required this.record}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_formatTimestamp(record.timestamp)} ${record.byteLength}B from ${record.callerIdentity}', + style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + ), + const SizedBox(height: 2), + SelectableText( + record.payload, + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + maxLines: 4, + minLines: 1, + ), + ], + ); + } +} + +class _ResultPanel extends StatelessWidget { + final _SendResult result; + + const _ResultPanel({required this.result}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isSuccess = result.isSuccess; + final color = isSuccess ? Colors.green : Colors.red; + final title = isSuccess + ? 'Response (${utf8.encode(result.response!).length}B, ${result.elapsed.inMilliseconds}ms)' + : _errorTitle(result.error!, result.elapsed); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: color.withValues(alpha: 0.7)), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.labelMedium?.copyWith(color: color, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 6), + SelectableText( + isSuccess ? result.response! : _errorBody(result.error!), + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + ), + ], + ), + ); + } + + static String _errorTitle(Object e, Duration elapsed) { + if (e is RpcError) { + return 'RpcError ${e.code} (${elapsed.inMilliseconds}ms)'; + } + return 'Error (${elapsed.inMilliseconds}ms)'; + } + + static String _errorBody(Object e) { + if (e is RpcError) { + final data = e.data; + final dataLine = (data != null && data.isNotEmpty) ? '\ndata: $data' : ''; + return '${e.message}$dataLine'; + } + return e.toString(); + } +} + +class _SendResult { + final String? response; + final Object? error; + final Duration elapsed; + final bool isSuccess; + + _SendResult.success({required String this.response, required this.elapsed}) + : error = null, + isSuccess = true; + + _SendResult.error({required Object this.error, required this.elapsed}) + : response = null, + isSuccess = false; +} + +String _formatTimestamp(DateTime t) { + String two(int n) => n.toString().padLeft(2, '0'); + String three(int n) => n.toString().padLeft(3, '0'); + return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}.${three(t.millisecond)}'; +} From 1328905ff9c03c40dc0df16e2aace6f4eac08ee3 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 15 May 2026 11:44:47 -0400 Subject: [PATCH 2/8] fix: address some clashing colors --- example/lib/widgets/rpc_test_sheet.dart | 36 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/example/lib/widgets/rpc_test_sheet.dart b/example/lib/widgets/rpc_test_sheet.dart index 927fee5b0..ea8807591 100644 --- a/example/lib/widgets/rpc_test_sheet.dart +++ b/example/lib/widgets/rpc_test_sheet.dart @@ -36,7 +36,9 @@ class RpcTestController extends ChangeNotifier { bool isRegistered(String topic) => _handlers.any((h) => h.topic == topic); void registerHandler(Room room, String topic, String staticResponse) { - if (isRegistered(topic)) return; + if (isRegistered(topic)) { + return; + } _room = room; final entry = RpcHandlerEntry(topic: topic, staticResponse: staticResponse); _handlers.add(entry); @@ -59,7 +61,9 @@ class RpcTestController extends ChangeNotifier { void unregisterHandler(String topic) { final idx = _handlers.indexWhere((h) => h.topic == topic); - if (idx < 0) return; + if (idx < 0) { + return; + } _room?.unregisterRpcMethod(topic); _handlers.removeAt(idx); notifyListeners(); @@ -151,7 +155,9 @@ class _RpcTestSheetState extends State { final identity = _selectedIdentity; final method = _methodCtl.text.trim(); final local = widget.room.localParticipant; - if (identity == null || method.isEmpty || local == null) return; + if (identity == null || method.isEmpty || local == null) { + return; + } final payload = _payloadCtl.text; setState(() { _isSending = true; @@ -167,13 +173,17 @@ class _RpcTestSheetState extends State { ), ); stopwatch.stop(); - if (!mounted) return; + if (!mounted) { + return; + } setState(() { _lastResult = _SendResult.success(response: response, elapsed: stopwatch.elapsed); }); } catch (e) { stopwatch.stop(); - if (!mounted) return; + if (!mounted) { + return; + } setState(() { _lastResult = _SendResult.error(error: e, elapsed: stopwatch.elapsed); }); @@ -188,7 +198,9 @@ class _RpcTestSheetState extends State { void _register() { final topic = _handlerTopicCtl.text.trim(); - if (topic.isEmpty) return; + if (topic.isEmpty) { + return; + } if (widget.controller.isRegistered(topic)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Handler for "$topic" is already registered')), @@ -201,7 +213,9 @@ class _RpcTestSheetState extends State { void _applyPreset(String preset, TextEditingController target) { final fn = _payloadPresets[preset]; - if (fn == null) return; + if (fn == null) { + return; + } target.text = fn(); } @@ -459,7 +473,7 @@ class _HandlerCardState extends State<_HandlerCard> { Expanded( child: Text( entry.topic, - style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold, color: theme.scaffoldBackgroundColor), ), ), TextButton.icon( @@ -480,6 +494,7 @@ class _HandlerCardState extends State<_HandlerCard> { isDense: true, ), onChanged: (value) => widget.controller.updateStaticResponse(entry.topic, value), + style: TextStyle(color: Theme.of(context).scaffoldBackgroundColor), ), const SizedBox(height: 8), Row( @@ -495,7 +510,10 @@ class _HandlerCardState extends State<_HandlerCard> { _responseCtl.text = value; widget.controller.updateStaticResponse(entry.topic, value); }, - child: Text(preset), + child: Text( + preset, + style: theme.textTheme.bodySmall?.copyWith(color: theme.cardColor), + ), ), ), ), From ecf4daa8e70f642c6b62f6ab56889c7b0e8eebcb Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 15 May 2026 11:54:22 -0400 Subject: [PATCH 3/8] fix: run dart format --- example/lib/widgets/rpc_test_sheet.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example/lib/widgets/rpc_test_sheet.dart b/example/lib/widgets/rpc_test_sheet.dart index ea8807591..0ce9f9abe 100644 --- a/example/lib/widgets/rpc_test_sheet.dart +++ b/example/lib/widgets/rpc_test_sheet.dart @@ -473,7 +473,8 @@ class _HandlerCardState extends State<_HandlerCard> { Expanded( child: Text( entry.topic, - style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold, color: theme.scaffoldBackgroundColor), + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold, color: theme.scaffoldBackgroundColor), ), ), TextButton.icon( From 18fa53f724b28189418999ed2baa4ff8cf919435 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:06:26 +0900 Subject: [PATCH 4/8] fix(example): harden the RPC tester controller Register the method with the room before tracking it, so a method already registered by another component does not leave a phantom card whose unregister button would remove a handler the tester does not own. Cap the per handler invocation log at 200 entries and stop notifying listeners from in flight handlers after disposal. --- example/lib/widgets/rpc_test_sheet.dart | 47 +++++++++++++++++-------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/example/lib/widgets/rpc_test_sheet.dart b/example/lib/widgets/rpc_test_sheet.dart index d3a138133..b4f02fd37 100644 --- a/example/lib/widgets/rpc_test_sheet.dart +++ b/example/lib/widgets/rpc_test_sheet.dart @@ -28,8 +28,13 @@ class RpcHandlerEntry { } class RpcTestController extends ChangeNotifier { + // Oldest invocations are dropped past this point so a chatty peer cannot + // grow the log without bound. + static const _maxInvocationsPerHandler = 200; + final List _handlers = []; Room? _room; + bool _disposed = false; List get handlers => List.unmodifiable(_handlers); @@ -60,21 +65,33 @@ class RpcTestController extends ChangeNotifier { } final entry = RpcHandlerEntry(topic: topic, staticResponse: staticResponse); + try { + room.registerRpcMethod(topic, (data) async { + final bytes = utf8.encode(data.payload).length; + entry.invocations.insert( + 0, + RpcInvocationRecord( + timestamp: DateTime.now(), + byteLength: bytes, + payload: data.payload, + callerIdentity: data.callerIdentity, + ), + ); + if (entry.invocations.length > _maxInvocationsPerHandler) { + entry.invocations.removeLast(); + } + if (!_disposed) { + notifyListeners(); + } + return entry.staticResponse; + }); + } on Exception { + // The room throws when another component already registered this + // method. Adding a card for it would let the tester unregister a + // handler it does not own. + return false; + } _handlers.add(entry); - room.registerRpcMethod(topic, (data) async { - final bytes = utf8.encode(data.payload).length; - entry.invocations.insert( - 0, - RpcInvocationRecord( - timestamp: DateTime.now(), - byteLength: bytes, - payload: data.payload, - callerIdentity: data.callerIdentity, - ), - ); - notifyListeners(); - return entry.staticResponse; - }); notifyListeners(); return true; } @@ -106,6 +123,8 @@ class RpcTestController extends ChangeNotifier { @override void dispose() { + // The flag keeps in flight RPC handlers from notifying after disposal. + _disposed = true; _unregisterAll(); _room = null; super.dispose(); From d8329dc22d24b9010e2f0caccfe313ced231618c Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:07:35 +0900 Subject: [PATCH 5/8] fix(example): keep the RPC destination dropdown valid when participants leave The form field only reads initialValue when its state is created, so a stale selection could outlive its participant and trip the framework assert that the value must be among the items. Key the field by the participant list and validate the selection before sending. --- example/lib/widgets/rpc_test_sheet.dart | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/example/lib/widgets/rpc_test_sheet.dart b/example/lib/widgets/rpc_test_sheet.dart index b4f02fd37..d68bf95d9 100644 --- a/example/lib/widgets/rpc_test_sheet.dart +++ b/example/lib/widgets/rpc_test_sheet.dart @@ -212,8 +212,13 @@ class _RpcTestSheetState extends State { super.dispose(); } + // The selected identity is only usable while that participant is still in + // the room. + String? get _validSelectedIdentity => + widget.room.remoteParticipants.values.any((p) => p.identity == _selectedIdentity) ? _selectedIdentity : null; + Future _send() async { - final identity = _selectedIdentity; + final identity = _validSelectedIdentity; final method = _methodCtl.text.trim(); final local = widget.room.localParticipant; if (identity == null || method.isEmpty || local == null) { @@ -340,7 +345,12 @@ class _RpcTestSheetState extends State { final remotes = widget.room.remoteParticipants.values.toList(); final identities = remotes.map((p) => p.identity).toList(); final currentValue = identities.contains(_selectedIdentity) ? _selectedIdentity : null; + // The form field only reads initialValue when its state is + // created, so key it by the participant list. Otherwise a + // selection can outlive its participant and trip the framework + // assert that the value must be among the items. return DropdownButtonFormField( + key: ValueKey(identities.join(',')), initialValue: currentValue, decoration: const InputDecoration( labelText: 'Destination', @@ -420,7 +430,7 @@ class _RpcTestSheetState extends State { bool _canSend() => !_isSending && - _selectedIdentity != null && + _validSelectedIdentity != null && _methodCtl.text.trim().isNotEmpty && widget.room.localParticipant != null; From ddca52160eb5b47fea37a2e7f88a2211112eaf8b Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:08:00 +0900 Subject: [PATCH 6/8] chore(example): drop redundant orElse throw, firstWhere already throws StateError --- example/lib/widgets/rpc_test_sheet.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/example/lib/widgets/rpc_test_sheet.dart b/example/lib/widgets/rpc_test_sheet.dart index d68bf95d9..0411d2880 100644 --- a/example/lib/widgets/rpc_test_sheet.dart +++ b/example/lib/widgets/rpc_test_sheet.dart @@ -114,10 +114,7 @@ class RpcTestController extends ChangeNotifier { // is needed. Skip notifyListeners to avoid rebuild loops with the // editing TextField that drives this method. void updateStaticResponse(String topic, String response) { - final entry = _handlers.firstWhere( - (h) => h.topic == topic, - orElse: () => throw StateError('topic $topic not registered'), - ); + final entry = _handlers.firstWhere((h) => h.topic == topic); entry.staticResponse = response; } From 975c24822e772773b4d3507510a754116656182e Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:18:34 +0900 Subject: [PATCH 7/8] chore: add changeset --- .changes/harden-rpc-tester | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/harden-rpc-tester diff --git a/.changes/harden-rpc-tester b/.changes/harden-rpc-tester new file mode 100644 index 000000000..16cea7aca --- /dev/null +++ b/.changes/harden-rpc-tester @@ -0,0 +1 @@ +patch type="fixed" "Example RPC tester: safe handler registration, capped invocation log, and valid destination selection after participants leave" From 726b1f9552e5b2214f2a37badd4d281ed04163dd Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:18:47 +0900 Subject: [PATCH 8/8] chore: add changeset --- .changes/harden-rpc-tester | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/harden-rpc-tester b/.changes/harden-rpc-tester index 16cea7aca..94f179c02 100644 --- a/.changes/harden-rpc-tester +++ b/.changes/harden-rpc-tester @@ -1 +1 @@ -patch type="fixed" "Example RPC tester: safe handler registration, capped invocation log, and valid destination selection after participants leave" +patch type="fixed" "Harden the example RPC tester"