From cdd2af1a35257ec8789acde2f99df89ac6e855d4 Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Mon, 2 Nov 2026 20:27:31 -0500 Subject: [PATCH] fix(mobile): let starter setup retry after a connection failure (#8967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary After joining a community, “Retry setup” can keep failing until Buzz restarts because it reuses an earlier connection error. A previously successful wait can also let a retry proceed while disconnected. Each setup attempt now waits for the current relay connection, retaining the community check and 15-second timeout. This fixes retry recovery; the original reported setup failure remains unexplained. ### Related issue None found. ### Testing Two provider regressions fail on main and pass with this fix: retry after an interrupted wait, and retry after a previously connected session disconnects. In an iOS simulator comparison with an injected first-wait error, the old build failed every retry. The fixed build completed setup and displayed both starter channels without restarting. This proves recovery from the injected error, not the original failure. Reconnect timing after a previously successful wait is covered by the provider regression only. ## Screenshots iOS simulator against a local relay, running head 5a95f8a85 plus an uncommitted first-wait failure injection (the injection fails only the first connection wait). ### First attempt fails and offers Retry setup ![First attempt fails and offers Retry setup](https://github.com/user-attachments/assets/09256e06-e73d-4f9b-9225-75c8e03b21a5) ### Retry completes and shows both starter channels ![Retry completes and shows both starter channels](https://github.com/user-attachments/assets/b31e4b0e-0a07-4b8b-b55d-8a1ca4608bda) Signed-off-by: John Tennant Co-authored-by: Damon <8a08290d889f1c68921542bfe9631d4cd671be9bea5f1114951f386819e4c10a@buzz.block.builderlab.xyz> --- 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); +}