fix(mobile): let starter setup retry after a connection failure (#8967)

## 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 <jtennant@squareup.com>
Co-authored-by: Damon <8a08290d889f1c68921542bfe9631d4cd671be9bea5f1114951f386819e4c10a@buzz.block.builderlab.xyz>
This commit is contained in:
John Matthew Tennant
2026-11-02 20:27:31 -05:00
committed by GitHub
parent 0ec74838a5
commit cdd2af1a35
2 changed files with 132 additions and 18 deletions

View File

@@ -57,29 +57,26 @@ const _starterChannels = [
),
];
final _inviteRelayConnectedProvider = FutureProvider.family<void, String>((
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<void> _waitForInviteRelay(Ref ref, bool Function() isScopeCurrent) {
final connected = Completer<void>();
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)

View File

@@ -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<InviteJoinRecovery> 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<String?> 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<StateError>()));
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<List<Channel>> build() async => _starters;
@override
Future<List<Channel>> 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);
}