mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
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:
@@ -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)
|
||||||
|
|||||||
119
mobile/test/features/invites/invite_relay_wait_test.dart
Normal file
119
mobile/test/features/invites/invite_relay_wait_test.dart
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user