From 5a95f8a85fde8441a6378830af74149c85cadfb8 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 2 Nov 2026 16:38:06 -0500 Subject: [PATCH] fix(mobile): wait for a live connection on every starter setup retry Invite recovery waited for the relay through a non-autoDispose FutureProvider.family keyed by relay origin. Riverpod caches both of its outcomes, so a wait that failed once made every Retry setup fail immediately with the same error until the app was relaunched, and a wait that succeeded once let later retries skip waiting after the connection was lost. Each attempt now listens to the live session itself and times out after 15 seconds as before. Co-authored-by: Damon <8a08290d889f1c68921542bfe9631d4cd671be9bea5f1114951f386819e4c10a@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- mobile/lib/app.dart | 31 ++--- .../invites/invite_relay_wait_test.dart | 119 ++++++++++++++++++ 2 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 mobile/test/features/invites/invite_relay_wait_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 110309806d..0f0b357a46 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -57,29 +57,26 @@ const _starterChannels = [ ), ]; -final _inviteRelayConnectedProvider = FutureProvider.family(( - ref, - expectedRelayUrl, -) async { - final currentConfig = ref.read(relayConfigProvider); - if (currentConfig.baseUrl != expectedRelayUrl) { - throw StateError('Active community changed before invite recovery'); - } - if (ref.read(relaySessionProvider).status == SessionStatus.connected) return; - +/// Waits for this attempt's community to hold an authenticated connection. +/// +/// Each attempt observes the live session, so a retry never reuses an earlier +/// attempt's failure or a connection that has since been lost. +Future _waitForInviteRelay(Ref ref, bool Function() isScopeCurrent) { final connected = Completer(); - ref.listen(relaySessionProvider, (_, next) { + final sub = ref.listen(relaySessionProvider, (_, next) { if (connected.isCompleted) return; - if (ref.read(relayConfigProvider).baseUrl != expectedRelayUrl) { + if (!isScopeCurrent()) { connected.completeError( StateError('Active community changed during invite recovery'), ); } else if (next.status == SessionStatus.connected) { connected.complete(); } - }); - await connected.future; -}); + }, fireImmediately: true); + return connected.future + .timeout(const Duration(seconds: 15)) + .whenComplete(sub.close); +} /// App-level bridge from invite joining to the channels feature. class MobileInviteJoinRecovery implements InviteJoinRecovery { @@ -241,9 +238,7 @@ InviteJoinRecovery buildMobileInviteJoinRecovery( ensureScopeCurrent(); await ref.read(activeCommunityProvider.future); ensureScopeCurrent(); - await ref - .read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future) - .timeout(const Duration(seconds: 15)); + await _waitForInviteRelay(ref, isScopeCurrent); ensureScopeCurrent(); final channels = await ref .read(channelsProvider.notifier) diff --git a/mobile/test/features/invites/invite_relay_wait_test.dart b/mobile/test/features/invites/invite_relay_wait_test.dart new file mode 100644 index 0000000000..4a00448965 --- /dev/null +++ b/mobile/test/features/invites/invite_relay_wait_test.dart @@ -0,0 +1,119 @@ +import 'package:buzz/app.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/invites/invite_join_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +const _relay = 'https://relay.example'; + +void main() { + late nostr.Keys keys; + late InviteJoinRecoveryScope scope; + late _Channels channels; + late ProviderContainer container; + late Provider recovery; + + setUp(() { + keys = nostr.Keys.generate(); + scope = InviteJoinRecoveryScope(relayHttpOrigin: _relay, nsec: keys.nsec); + channels = _Channels(); + // Production builds a fresh recovery for every attempt. + recovery = Provider((ref) => buildMobileInviteJoinRecovery(ref, scope)); + container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => _Config(keys.nsec)), + relaySessionProvider.overrideWith(_Session.new), + activeCommunityProvider.overrideWith( + (ref) async => + Community.create(name: 'Test', relayUrl: _relay, nsec: keys.nsec), + ), + channelsProvider.overrideWith(() => channels), + ], + ); + addTearDown(container.dispose); + }); + + Future attempt() { + container.invalidate(recovery); + return container.read(recovery).ensureStarterChannels(); + } + + void setStatus(SessionStatus status) => + (container.read(relaySessionProvider.notifier) as _Session).set(status); + + _Config config() => container.read(relayConfigProvider.notifier) as _Config; + + test('retry succeeds after an interrupted connection wait', () async { + await container.read(activeCommunityProvider.future); + setStatus(SessionStatus.connecting); + final first = attempt(); + await pumpEventQueue(); + config().update(baseUrl: 'https://other.example', nsec: keys.nsec); + setStatus(SessionStatus.reconnecting); + await expectLater(first, throwsA(isA())); + + config().update(baseUrl: _relay, nsec: keys.nsec); + setStatus(SessionStatus.connected); + expect(await attempt(), 'welcome-everyone'); + }); + + test('retry waits for a connection lost after an earlier wait', () async { + await container.read(activeCommunityProvider.future); + setStatus(SessionStatus.connected); + expect(await attempt(), 'welcome-everyone'); + expect(channels.freshReads, 1); + + setStatus(SessionStatus.disconnected); + final retry = attempt(); + await pumpEventQueue(); + expect(channels.freshReads, 1); + + setStatus(SessionStatus.connected); + expect(await retry, 'welcome-everyone'); + expect(channels.freshReads, 2); + }); +} + +class _Channels extends ChannelsNotifier { + int freshReads = 0; + final _starters = [ + for (final name in ['general', 'welcome-everyone']) + Channel( + id: name, + name: name, + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'me', + createdAt: DateTime.utc(2026), + memberCount: 1, + isMember: true, + ), + ]; + @override + Future> build() async => _starters; + @override + Future> fetchFresh({bool fetchDirectory = false}) async { + freshReads++; + return _starters; + } +} + +class _Config extends RelayConfigNotifier { + _Config(this.nsec); + final String nsec; + @override + RelayConfig build() => RelayConfig(baseUrl: _relay, nsec: nsec); +} + +class _Session extends RelaySessionNotifier { + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); + void set(SessionStatus status) => state = SessionState(status: status); +}