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 <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-11-02 16:38:06 -05:00
parent 009dc103f6
commit 5a95f8a85f
2 changed files with 132 additions and 18 deletions

View File

@@ -57,29 +57,26 @@ const _starterChannels = [
), ),
]; ];
final _inviteRelayConnectedProvider = FutureProvider.family<void, String>(( /// Waits for this attempt's community to hold an authenticated connection.
ref, ///
expectedRelayUrl, /// Each attempt observes the live session, so a retry never reuses an earlier
) async { /// attempt's failure or a connection that has since been lost.
final currentConfig = ref.read(relayConfigProvider); Future<void> _waitForInviteRelay(Ref ref, bool Function() isScopeCurrent) {
if (currentConfig.baseUrl != expectedRelayUrl) {
throw StateError('Active community changed before invite recovery');
}
if (ref.read(relaySessionProvider).status == SessionStatus.connected) return;
final connected = Completer<void>(); final connected = Completer<void>();
ref.listen(relaySessionProvider, (_, next) { final sub = ref.listen(relaySessionProvider, (_, next) {
if (connected.isCompleted) return; if (connected.isCompleted) return;
if (ref.read(relayConfigProvider).baseUrl != expectedRelayUrl) { if (!isScopeCurrent()) {
connected.completeError( connected.completeError(
StateError('Active community changed during invite recovery'), StateError('Active community changed during invite recovery'),
); );
} else if (next.status == SessionStatus.connected) { } else if (next.status == SessionStatus.connected) {
connected.complete(); connected.complete();
} }
}); }, fireImmediately: true);
await connected.future; return connected.future
}); .timeout(const Duration(seconds: 15))
.whenComplete(sub.close);
}
/// App-level bridge from invite joining to the channels feature. /// App-level bridge from invite joining to the channels feature.
class MobileInviteJoinRecovery implements InviteJoinRecovery { class MobileInviteJoinRecovery implements InviteJoinRecovery {
@@ -241,9 +238,7 @@ InviteJoinRecovery buildMobileInviteJoinRecovery(
ensureScopeCurrent(); ensureScopeCurrent();
await ref.read(activeCommunityProvider.future); await ref.read(activeCommunityProvider.future);
ensureScopeCurrent(); ensureScopeCurrent();
await ref await _waitForInviteRelay(ref, isScopeCurrent);
.read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future)
.timeout(const Duration(seconds: 15));
ensureScopeCurrent(); ensureScopeCurrent();
final channels = await ref final channels = await ref
.read(channelsProvider.notifier) .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);
}