mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
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

### Retry completes and shows both starter channels

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Damon <8a08290d889f1c68921542bfe9631d4cd671be9bea5f1114951f386819e4c10a@buzz.block.builderlab.xyz>
This commit is contained in:
committed by
GitHub
parent
0ec74838a5
commit
cdd2af1a35
@@ -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)
|
||||
|
||||
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