From 8f6e19f59f3f6f2931ad3676cb25652125168053 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 14 Sep 2026 14:17:51 -0700 Subject: [PATCH 1/2] perf(mobile): avoid sorting live channel windows twice Signed-off-by: Tom Brow --- .../channels/channel_messages_provider.dart | 9 +- mobile/test/benchmark/README.md | 30 ++++ .../benchmark/channel_live_burst_test.dart | 134 ++++++++++++++++++ .../channel_messages_provider_test.dart | 68 +++++++++ 4 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 mobile/test/benchmark/README.md create mode 100644 mobile/test/benchmark/channel_live_burst_test.dart diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index e910f565b4b..32f4824df24 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -229,9 +229,12 @@ class ChannelMessagesNotifier extends Notifier>> { void _handleWindowLiveEvent(NostrEvent event) { if (!_mergeWindowEventIntoStore(event)) return; - final flattened = _withDeepLinkEvents( - flattenChannelWindowEvents(_windowStore), - ); + final windowEvents = flattenChannelWindowEvents(_windowStore); + // Flattening already orders the window. Only merge and sort again when + // there are retained deep-link events to include. + final flattened = _deepLinkEvents.isEmpty + ? windowEvents + : _withDeepLinkEvents(windowEvents); _lastKnownMessages = flattened; state = AsyncData(flattened); } diff --git a/mobile/test/benchmark/README.md b/mobile/test/benchmark/README.md new file mode 100644 index 00000000000..8b9dad3788d --- /dev/null +++ b/mobile/test/benchmark/README.md @@ -0,0 +1,30 @@ +# Mobile performance diagnostics + +With the repository's Hermit environment active, run from `mobile/`: + +```sh +flutter test --dart-define=BUZZ_RUN_BENCHMARKS=true test/benchmark/channel_live_burst_test.dart +``` + +The diagnostic is skipped by default. It does not use a relay, credentials, or +community content. + +The buffered-channel diagnostic sends synthetic events through the real +`RelaySessionNotifier` event buffer and `ChannelMessagesNotifier` live callback. +Only connection state and history responses are replaced. It measures one +synchronous flush and a zero-delay timer queued just before that flush. The timer +shows how long other event-loop work waits behind the consumer callbacks. + +It varies retained history (50, 500, 2000 events) and live burst size (50, 200, +1000 events). Each case warms up twice before emitting four JSON measurement +records in microseconds. Compare medians on the same host under similar load. +No wall-clock threshold is asserted because debug/JIT compilation and host load +make it unsuitable for a stable CI performance gate. Final event counts are +asserted to ensure the measured work actually completes. + +This is a bounded stress diagnostic, not a representative release benchmark. +Retained history is modeled as one synthetic page instead of real pagination. +It excludes widgets, media, network traffic, signature verification, and other +subscriptions. It can identify synchronous consumer costs, but cannot establish +that a measured burst occurred in a real community or predict release frame +latency. diff --git a/mobile/test/benchmark/channel_live_burst_test.dart b/mobile/test/benchmark/channel_live_burst_test.dart new file mode 100644 index 00000000000..c70890ab8c4 --- /dev/null +++ b/mobile/test/benchmark/channel_live_burst_test.dart @@ -0,0 +1,134 @@ +// Opt-in synthetic diagnostic; see README.md for invocation and limitations. +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/channels/channel_messages_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + test( + 'buffered live channel delivery', + () async { + for (final historySize in [50, 500, 2000]) { + for (final burstSize in [50, 200, 1000]) { + // Discard the first two runs to reduce debug/JIT warm-up effects. + for (var run = 0; run < 6; run++) { + await _measureBurst(historySize, burstSize, run); + } + } + } + }, + skip: !const bool.fromEnvironment('BUZZ_RUN_BENCHMARKS'), + timeout: const Timeout(Duration(minutes: 3)), + ); +} + +Future _measureBurst(int historySize, int burstSize, int run) async { + final session = _SyntheticSession(historySize); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + try { + final loaded = Completer(); + container.listen(channelMessagesProvider(_channelId), (_, next) { + if (!loaded.isCompleted && next.value?.length == historySize) { + loaded.complete(); + } + }); + await loaded.future.timeout(const Duration(seconds: 5)); + + for (var index = 0; index < burstSize; index++) { + session.debugHandleMessage([ + 'EVENT', + 'l-1', + _event('live-$index', historySize + index).toJson(), + ]); + } + final tick = Completer(); + final watch = Stopwatch()..start(); + Timer.run(() => tick.complete(watch.elapsedMicroseconds)); + session.debugFlushEventBuffer(); + final synchronousUs = watch.elapsedMicroseconds; + final eventLoopDelayUs = await tick.future; + + expect( + container.read(channelMessagesProvider(_channelId)).value!.length, + historySize + burstSize, + ); + if (run >= 2) { + debugPrint( + jsonEncode({ + 'history': historySize, + 'burst': burstSize, + 'run': run - 2, + 'synchronous_us': synchronousUs, + 'event_loop_delay_us': eventLoopDelayUs, + }), + ); + } + } finally { + container.dispose(); + // The original 16 ms buffer timer remains armed after a debug flush. + await Future.delayed(const Duration(milliseconds: 20)); + } +} + +const _channelId = '11111111-1111-4111-8111-111111111111'; + +NostrEvent _event(String id, int createdAt) => NostrEvent( + id: id, + pubkey: 'synthetic-author', + createdAt: createdAt, + kind: EventKind.streamMessageV2, + tags: const [ + ['h', _channelId], + ], + content: 'Synthetic message', + sig: 'synthetic-signature', +); + +class _SyntheticSession extends RelaySessionNotifier { + final int historySize; + + _SyntheticSession(this.historySize); + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) { + // Preserve the real subscription and buffer delivery implementation. This + // fixture creates exactly one subscription, whose generated id is l-1. + final ready = super.subscribe(filter, onEvent, onClosed: onClosed); + debugHandleMessage(['EOSE', 'l-1']); + return ready; + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async => [ + // Model retained history in one synthetic page; real windows page by 50. + for (var index = historySize - 1; index >= 0; index--) + _event('history-$index', index), + NostrEvent( + id: 'bounds', + pubkey: 'synthetic-relay', + createdAt: 0, + kind: EventKind.channelWindowBounds, + tags: const [ + ['d', '$_channelId:head'], + ], + content: jsonEncode({'has_more': false, 'next_cursor': null}), + sig: 'synthetic-signature', + ), + ]; +} diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index 80eefc5b98e..e215fb9884c 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -11,6 +11,74 @@ import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { + for (final retainDeepLink in [false, true]) { + test( + 'live window keeps chronological order with retained deep link: $retainDeepLink', + () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'newer-history', createdAt: 20), + _event(id: 'older-history', createdAt: 10), + _bounds(), + ], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + if (retainDeepLink) { + final load = notifier.loadEventsById(['deep-link']); + relaySession.completeTargetHistory([ + _event(id: 'deep-link', createdAt: 5), + ]); + await load; + } + + // Older live rows must insert in order, including the descending-id + // tie-break within a second, on both sides of the deep-link fast path. + relaySession.emit(_event(id: 'a-live', createdAt: 15)); + relaySession.emit(_event(id: 'z-live', createdAt: 15)); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value! + .map((event) => event.id), + [ + if (retainDeepLink) 'deep-link', + 'older-history', + 'z-live', + 'a-live', + 'newer-history', + ], + ); + + if (retainDeepLink) { + notifier.releaseDeepLinkEvents(['deep-link']); + relaySession.emit(_event(id: 'newest-live', createdAt: 30)); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value! + .map((event) => event.id), + [ + 'older-history', + 'z-live', + 'a-live', + 'newer-history', + 'newest-live', + ], + ); + } + }, + ); + } + test( 'keeps live events that arrive while initial history is loading', () async { From 77e6594d7ac77e4ce012bc2e4d810f852efd0d0f Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 14 Sep 2026 14:45:33 -0700 Subject: [PATCH 2/2] test(mobile): guard live-window fast-path work Signed-off-by: Tom Brow --- .../channel_messages_provider_test.dart | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index e215fb9884c..9e2d93d2a78 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -11,6 +11,37 @@ import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { + test('live window without deep links does not rescan flattened ids', () async { + var historyIdReads = 0; + final history = _IdReadTrackingEvent( + _event(id: 'history', createdAt: 10), + onIdRead: () => historyIdReads++, + ); + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [history, _bounds()], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + + historyIdReads = 0; + relaySession.emit(_event(id: 'live', createdAt: 20)); + + // One read checks page membership; one builds the flattened window. The + // distinct timestamps need no id tie-break when sorting. A redundant + // deep-link merge would read the historical id a third time to build its + // dedup set. Allow fewer reads if either required pass is optimized later. + expect(historyIdReads, lessThanOrEqualTo(2)); + expect( + container.read(channelMessagesProvider(_channelId)).value!.length, + 2, + ); + }); + for (final retainDeepLink in [false, true]) { test( 'live window keeps chronological order with retained deep link: $retainDeepLink', @@ -1035,6 +1066,27 @@ void main() { const _channelId = '11111111-1111-4111-8111-111111111111'; +class _IdReadTrackingEvent extends NostrEvent { + final void Function() onIdRead; + + _IdReadTrackingEvent(NostrEvent event, {required this.onIdRead}) + : super( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + ); + + @override + String get id { + onIdRead(); + return super.id; + } +} + ProviderContainer _buildContainer(_RecordingRelaySessionNotifier relaySession) { return ProviderContainer( overrides: [relaySessionProvider.overrideWith(() => relaySession)],