fix(market): recover by itself when the shared connection comes up
Fresh installs could sit on 'can't reach the servers' forever: the offers cubit captured the transport once at build time, the shared connection only retried on a connectivity CHANGE, and a silently-filtered relay could stall the pool for minutes. - SocialConnection: retry with backoff after a failed attempt while started and not knowingly offline (injectable schedule for tests) - OffersCubit: follow connection.sessions, re-attach the transport and re-run the last discovery on (re)connect; announce drops via connectionEpoch - market _init: record the wanted area on the cubit even while offline - NostrOfferTransport.discoverPage: sort a copy (channel lists may be unmodifiable)
This commit is contained in:
parent
981cf10048
commit
8a7dc8d3dc
6 changed files with 273 additions and 9 deletions
|
|
@ -45,11 +45,13 @@ void main() {
|
|||
required List<FakeChannel> opened,
|
||||
Stream<bool>? online,
|
||||
bool Function()? fail,
|
||||
List<Duration>? retrySchedule,
|
||||
}) =>
|
||||
SocialConnection(
|
||||
social: social,
|
||||
settings: settings,
|
||||
online: online,
|
||||
retrySchedule: retrySchedule,
|
||||
open: (_) async {
|
||||
if (fail?.call() ?? false) throw StateError('unreachable');
|
||||
final ch = FakeChannel();
|
||||
|
|
@ -106,6 +108,69 @@ void main() {
|
|||
await online.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'started connection retries by itself after a failed first attempt, '
|
||||
'without any connectivity event', () async {
|
||||
// The fresh-install case: device online the whole time, but the very first
|
||||
// connect fails (cold DNS/TLS). The connectivity stream never fires, so
|
||||
// only the backoff retry can bring the market up without a manual Retry.
|
||||
final opened = <FakeChannel>[];
|
||||
var down = true;
|
||||
final conn = make(
|
||||
opened: opened,
|
||||
online: const Stream.empty(), // connectivity never speaks
|
||||
fail: () => down,
|
||||
retrySchedule: const [Duration(milliseconds: 5)],
|
||||
);
|
||||
final emitted = <SocialSession?>[];
|
||||
conn.sessions.listen(emitted.add);
|
||||
|
||||
conn.start();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(conn.current, isNull); // first attempt failed
|
||||
|
||||
down = false; // network path recovers
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
expect(conn.current, isNotNull, reason: 'backoff retry reconnected');
|
||||
expect(opened, hasLength(1));
|
||||
expect(emitted.last, isNotNull, reason: 'recovery announced on sessions');
|
||||
|
||||
await conn.dispose();
|
||||
});
|
||||
|
||||
test('a plain session() failure schedules no retry when never started',
|
||||
() async {
|
||||
// Widget tests build cubits against an un-started connection; a failed
|
||||
// one-shot session() must not leave a pending retry timer behind.
|
||||
final opened = <FakeChannel>[];
|
||||
final conn = make(
|
||||
opened: opened,
|
||||
fail: () => true,
|
||||
retrySchedule: const [Duration(milliseconds: 5)],
|
||||
);
|
||||
expect(await conn.session(), isNull);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(opened, isEmpty); // no background retry fired
|
||||
await conn.dispose();
|
||||
});
|
||||
|
||||
test('dispose cancels a pending backoff retry', () async {
|
||||
final opened = <FakeChannel>[];
|
||||
var down = true;
|
||||
final conn = make(
|
||||
opened: opened,
|
||||
online: const Stream.empty(),
|
||||
fail: () => down,
|
||||
retrySchedule: const [Duration(milliseconds: 20)],
|
||||
);
|
||||
conn.start();
|
||||
await Future<void>.delayed(Duration.zero); // first attempt fails
|
||||
await conn.dispose(); // cancels the scheduled retry
|
||||
down = false;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
expect(opened, isEmpty, reason: 'no reconnect after dispose');
|
||||
});
|
||||
|
||||
test('returns null when the relay is unreachable, and retries later',
|
||||
() async {
|
||||
final opened = <FakeChannel>[];
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import 'package:tane/data/variety_repository.dart';
|
|||
import 'package:tane/db/enums.dart';
|
||||
import 'package:tane/services/discovery_area.dart';
|
||||
import 'package:tane/services/offer_mapper.dart';
|
||||
import 'package:nostr/nostr.dart';
|
||||
import 'package:tane/services/offer_outbox.dart';
|
||||
import 'package:tane/services/social_connection.dart';
|
||||
import 'package:tane/services/social_service.dart';
|
||||
import 'package:tane/services/social_settings.dart';
|
||||
import 'package:tane/state/offers_cubit.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
|
@ -835,4 +839,111 @@ void main() {
|
|||
await cubit.close();
|
||||
});
|
||||
});
|
||||
|
||||
group('connection recovery (fresh-install fix)', () {
|
||||
const seedHex =
|
||||
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
||||
|
||||
late SocialService social;
|
||||
late SocialSettings settings;
|
||||
|
||||
setUp(() async {
|
||||
social = await SocialService.fromRootSeedHex(seedHex);
|
||||
settings = SocialSettings(InMemorySecretStore());
|
||||
});
|
||||
|
||||
SocialConnection makeConnection({
|
||||
required bool Function() down,
|
||||
Stream<bool>? online,
|
||||
}) =>
|
||||
SocialConnection(
|
||||
social: social,
|
||||
settings: settings,
|
||||
online: online ?? const Stream.empty(),
|
||||
retrySchedule: const [Duration(milliseconds: 5)],
|
||||
open: (_) async {
|
||||
if (down()) throw StateError('unreachable');
|
||||
return SocialSession(_NoopChannel());
|
||||
},
|
||||
);
|
||||
|
||||
test('comes online by itself when the shared connection connects later',
|
||||
() async {
|
||||
var down = true;
|
||||
final conn = makeConnection(down: () => down);
|
||||
final cubit = await createOffersCubit(conn);
|
||||
expect(cubit.isOnline, isFalse); // built while unreachable
|
||||
|
||||
final epochBefore = cubit.state.connectionEpoch;
|
||||
conn.start();
|
||||
down = false; // network path recovers; backoff retry reconnects
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(cubit.isOnline, isTrue, reason: 'no manual Retry needed');
|
||||
expect(cubit.state.connectionEpoch, greaterThan(epochBefore),
|
||||
reason: 'a state was emitted so the UI re-reads isOnline');
|
||||
|
||||
await cubit.close();
|
||||
await conn.dispose();
|
||||
});
|
||||
|
||||
test('re-runs the last discovery when the connection recovers', () async {
|
||||
var down = true;
|
||||
final conn = makeConnection(down: () => down);
|
||||
final cubit = await createOffersCubit(conn);
|
||||
|
||||
await cubit.discover('sp3e9'); // offline → error, but the wish is kept
|
||||
expect(cubit.state.error, 'offline');
|
||||
|
||||
conn.start();
|
||||
down = false;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(cubit.isOnline, isTrue);
|
||||
expect(cubit.state.areaGeohash, 'sp3e9', reason: 'discovery re-ran');
|
||||
expect(cubit.state.hasSearched, isTrue);
|
||||
expect(cubit.state.error, isNull, reason: 'offline error cleared');
|
||||
|
||||
await cubit.close();
|
||||
await conn.dispose();
|
||||
});
|
||||
|
||||
test('goes offline (and tells the UI) when the session drops', () async {
|
||||
final online = StreamController<bool>.broadcast();
|
||||
final conn = makeConnection(down: () => false, online: online.stream);
|
||||
conn.start();
|
||||
await pumpEventQueue();
|
||||
final cubit = await createOffersCubit(conn);
|
||||
expect(cubit.isOnline, isTrue);
|
||||
|
||||
final epochBefore = cubit.state.connectionEpoch;
|
||||
online.add(false); // network lost
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(cubit.isOnline, isFalse);
|
||||
expect(cubit.state.connectionEpoch, greaterThan(epochBefore));
|
||||
|
||||
await cubit.close();
|
||||
await conn.dispose();
|
||||
await online.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A no-op [NostrChannel]: just enough to build a [SocialSession] whose offer
|
||||
/// transport answers with empty results.
|
||||
class _NoopChannel implements NostrChannel {
|
||||
@override
|
||||
String get privateKeyHex => '00' * 32;
|
||||
@override
|
||||
String get publicKeyHex => 'ab' * 32;
|
||||
@override
|
||||
Future<({bool accepted, String message})> publish(Event event) async =>
|
||||
(accepted: true, message: '');
|
||||
@override
|
||||
Stream<Event> subscribe(Filter filter) => const Stream.empty();
|
||||
@override
|
||||
Future<List<Event>> reqOnce(Filter filter) async => const [];
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue