diff --git a/.changes/harden-rpc-tester b/.changes/harden-rpc-tester new file mode 100644 index 000000000..94f179c02 --- /dev/null +++ b/.changes/harden-rpc-tester @@ -0,0 +1 @@ +patch type="fixed" "Harden the example RPC tester" diff --git a/example/lib/widgets/rpc_test_sheet.dart b/example/lib/widgets/rpc_test_sheet.dart index d3a138133..0411d2880 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; } @@ -97,15 +114,14 @@ 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; } @override void dispose() { + // The flag keeps in flight RPC handlers from notifying after disposal. + _disposed = true; _unregisterAll(); _room = null; super.dispose(); @@ -193,8 +209,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) { @@ -321,7 +342,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', @@ -401,7 +427,7 @@ class _RpcTestSheetState extends State { bool _canSend() => !_isSending && - _selectedIdentity != null && + _validSelectedIdentity != null && _methodCtl.text.trim().isNotEmpty && widget.room.localParticipant != null;