perf(mobile): cut idle and resume sync churn (#8490)

## Summary

Mobile does background work nobody sees, and it waits on round trips it
doesn't need. This PR fixes four things:

- **Chats you've left let go.** A chat with no listener for 30 s drops
its live and typing subscriptions. Coming back shows the cached timeline
right away, then resubscribes and catches up.
- **No requests before AUTH.** The relay rejects any REQ that arrives
before NIP-42 AUTH completes. History requests are now held until the
socket authenticates, and they flush after the rate-limit gate. Live
subscriptions skip their REQ, and the replay on connect sends it.
`auth-required` is terminal for a live subscription, so a pre-AUTH live
REQ could be dropped for good.
- **Resume reconciles the channel list once.** `resumed` is published
before the session hears about it. So the channel list now decides one
microtask later: if the session is reconnecting, the connect does the
reconcile.
- **The 60 s backstop only runs in the foreground on a connected
session.** After an OS suspension, the missed tick fires at unfreeze,
about 450 ms before the app hears `resumed`, while the session still
looks connected. Without this, that tick triggered a second full sync.

Scope note: while the app is in the foreground, the backstop still
re-downloads membership every 60 s. That matches desktop, which
refetches every 60 s while focused. To drop it, mobile would need to
subscribe to the live membership notices (44100/44101), as desktop does.
That would be a follow-up.

### Related issue

N/A. This is follow-up work from the mobile perf dive. None found.

## Before and after

Simulator setup: iPhone 18 Pro simulator, debug build, a local relay
behind a proxy that adds ~120 ms RTT, and a seeded account with 120
channels. I scripted one identical run against each build:
1. Open 5 channels.
2. Visit 20 more channels.
3. Wait 40 s.
4. Stay idle in the foreground for 3 min.
5. Go to the background for 3 min.
6. Resume.

`main` is `ca3be5501`; the branch is `d77c4e86f`. The last amend changed
only test names. Since then, chat history waits for the live EOSE again
(see the note below), so channel open is back to `main`'s order.

| | `main` | this PR |
|---|---|---|
| Live subscriptions after visiting 20 chats | 61 | 11 |
| Resume: membership reads | 2 (244 kB) | 1 (122 kB) |
| Resume: websocket REQs | 36 | 31 |
| Resume: REQs sent before AUTH | 9 | 0 |
| Resume: REQs rejected as `auth-required` | 2 | 0 |
| Idle foreground, 3 min | 3 membership reads, 12 REQs | same |

Notes:
- **Channel open:** an earlier revision requested history without
waiting for EOSE, which cut the median open from 297 ms to 197 ms.
Review showed it can lose a message. The relay registers the live REQ
only after its access checks, so a message committed in between reached
neither the window read nor the subscription. A timestamp overlap only
covers messages signed within it. Keeping that speedup safely would mean
a second window read on every open, so it's left out.
- **Idle foreground is unchanged by design.** See the scope note above.
- **Background traffic:** the simulator suspends the app shortly after
it leaves the foreground, so both builds sent almost nothing during the
3 minutes in the background. The backstop fix shows up at unfreeze
instead, as the missing second membership read.
- **Resume inbound events:** in this one run, events received over the
websocket on resume were 247 kB on `main` and 338 kB on the branch. I
haven't attributed the difference.

## Test

These extend the existing journeys, plus one new journey:

- **`channel_messages_journey_test`:**
- The window read waits for the live REQ's admission, and a message
signed a minute earlier but committed before admission shows up.
- After the listener leaves, the chat keeps following for 29 s and stops
at 31 s.
- Coming back shows the cached rows immediately, then catches up on a
message sent while away.
- **`relay_session_test`:** a stale-connected resume sends nothing
before AUTH. Then exactly one history REQ and one live REQ go out after
AUTH.
- **New `channels_sync_journey_test`:** drives the real lifecycle,
session and channel notifier against `FakeRelay`:
  - A backgrounding just before a backstop tick sends nothing.
  - A resume after the session paused syncs once.
- An OS-suspension resume with AUTH held syncs once, after AUTH, and
sends no REQ before it.
- **`FakeRelay`:** gained `holdReq`, `holdAuth`, `before_id` pagination
and an injectable clock.

Each guard was mutation-checked, and every mutation failed its test:
- live REQ sent before AUTH
- history sent before AUTH
- resume refresh not deferred
- backstop without the lifecycle check
- history read alongside the live REQ
- a 30-day detach delay

Follow-up, not in this PR: a history REQ already in flight on a socket
that gets replaced still waits for its 8 s timeout. Princess Donut found
this in review; it predates this change.

## Validation

- `flutter analyze`: clean
- Full mobile suite at `678617b`: 3,696 passed
- Court review (Princess Donut on the relay session, Mongo on chat
subscriptions, Mordecai on the channel list plus a challenge pass): no
blocking findings. The rate-limit flush ordering came from that review.

**Human test:** Wes ran `da5077d` on the simulator with his real account
and confirmed it works. `460dc35` then fixed a review blocker: a chat
rebuilt while detached stayed detached. A retest of that path is
pending: leave a chat for more than 30 s, background the app for more
than 5 s, resume, then reopen the chat.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-10-26 10:24:43 -07:00
committed by GitHub
parent b8b1ab5b3c
commit d090ac24bb
9 changed files with 402 additions and 37 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -17,6 +19,10 @@ part 'channel_messages_provider/pagination.dart';
const _maxCachedRepliesPerRoot = 256; const _maxCachedRepliesPerRoot = 256;
const _maxCachedReplies = 2048; const _maxCachedReplies = 2048;
/// How long a chat nobody shows keeps its live subscription. A brief cover,
/// such as a profile pushed over the chat, returns without a catch-up fetch.
const _liveDetachDelay = Duration(seconds: 30);
const _channelLiveEventKinds = [ const _channelLiveEventKinds = [
...EventKind.channelEventKinds, ...EventKind.channelEventKinds,
EventKind.channelThreadSummary, EventKind.channelThreadSummary,
@@ -59,6 +65,8 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
ref.read(relaySessionProvider).status == SessionStatus.connected, ref.read(relaySessionProvider).status == SessionStatus.connected,
); );
bool _hasListeners = true; bool _hasListeners = true;
Timer? _liveDetachTimer;
bool _liveDetached = false;
late final _summaryRefreshes = ThreadSummaryRefreshQueue( late final _summaryRefreshes = ThreadSummaryRefreshQueue(
refresh: _refreshOverflowSummary, refresh: _refreshOverflowSummary,
canRun: () => canRun: () =>
@@ -115,18 +123,38 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
@override @override
AsyncValue<List<NostrEvent>> build() { AsyncValue<List<NostrEvent>> build() {
final sessionState = ref.watch(relaySessionProvider); final sessionState = ref.watch(relaySessionProvider);
// Riverpod defers rebuilding a provider nobody is listening to until it
// is listened to or read again, and then skips onResume. So a build
// always means the chat is wanted.
_hasListeners = true;
_liveDetached = false;
ref.onCancel(() { ref.onCancel(() {
_hasListeners = false; _hasListeners = false;
_deletionOwnerQueue.pause(); _deletionOwnerQueue.pause();
_summaryRefreshes.pause(); _summaryRefreshes.pause();
_liveDetachTimer?.cancel();
_liveDetachTimer = Timer(_liveDetachDelay, _detachLive);
}); });
ref.onResume(() { ref.onResume(() {
_hasListeners = true; _hasListeners = true;
_liveDetachTimer?.cancel();
_liveDetachTimer = null;
if (_liveDetached) {
// Keep the cached timeline on screen while a rebuild resubscribes
// and catches up, as it does after a reconnect. Life-cycle callbacks
// can't use ref, so rebuild just after.
_liveDetached = false;
Future.microtask(() {
if (ref.mounted) ref.invalidateSelf();
});
}
Future.microtask(_summaryRefreshes.resume); Future.microtask(_summaryRefreshes.resume);
Future.microtask(_deletionOwnerQueue.resume); Future.microtask(_deletionOwnerQueue.resume);
}); });
ref.onDispose(() { ref.onDispose(() {
_initVersion++; _initVersion++;
_liveDetachTimer?.cancel();
_liveDetachTimer = null;
_deletionOwnerQueue.pause(); _deletionOwnerQueue.pause();
_summaryRefreshes.pause(); _summaryRefreshes.pause();
_clearSubscription(); _clearSubscription();
@@ -868,6 +896,18 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
bool _isCurrentInit(int initVersion) => initVersion == _initVersion; bool _isCurrentInit(int initVersion) => initVersion == _initVersion;
/// Stops live work for a chat that stayed hidden; resume catches it up.
void _detachLive() {
_liveDetachTimer = null;
_liveDetached = true;
// Retire an unfinished init so its subscription can't install later.
_initVersion++;
_initInFlight = false;
_initialWindowQueryInFlight = false;
_liveSummaryRootsDuringInitialWindowQuery.clear();
_clearSubscription();
}
void _clearSubscription() { void _clearSubscription() {
_unsubscribe?.call(); _unsubscribe?.call();
_unsubscribe = null; _unsubscribe = null;

View File

@@ -27,11 +27,16 @@ class ChannelTypingNotifier extends Notifier<List<TypingEntry>> {
static const _ttlMs = 8000; static const _ttlMs = 8000;
static const _pruneIntervalMs = 1000; static const _pruneIntervalMs = 1000;
/// How long a channel nobody shows keeps its typing subscription.
static const _detachDelay = Duration(seconds: 30);
final String channelId; final String channelId;
void Function()? _unsubscribe; void Function()? _unsubscribe;
Timer? _pruneTimer; Timer? _pruneTimer;
Timer? _retryTimer; Timer? _retryTimer;
Timer? _healthyTimer; Timer? _healthyTimer;
Timer? _detachTimer;
bool _detached = false;
int _generation = 0; int _generation = 0;
final _activity = <(String, String?), _TypingActivity>{}; final _activity = <(String, String?), _TypingActivity>{};
@@ -42,18 +47,34 @@ class ChannelTypingNotifier extends Notifier<List<TypingEntry>> {
final sessionState = ref.watch(relaySessionProvider); final sessionState = ref.watch(relaySessionProvider);
final generation = ++_generation; final generation = ++_generation;
_activity.clear(); _activity.clear();
// Riverpod defers rebuilding a provider nobody is listening to until it
// is listened to or read again, and then skips onResume. So a build
// always means the channel is wanted.
_detached = false;
ref.onCancel(() {
_detachTimer?.cancel();
_detachTimer = Timer(_detachDelay, () {
_detachTimer = null;
_detached = true;
_stop();
state = [];
});
});
ref.onResume(() {
_detachTimer?.cancel();
_detachTimer = null;
if (_detached) {
_detached = false;
Future.microtask(() {
if (ref.mounted) ref.invalidateSelf();
});
}
});
ref.onDispose(() { ref.onDispose(() {
_generation++; _detachTimer?.cancel();
_activity.clear(); _detachTimer = null;
_unsubscribe?.call(); _stop();
_unsubscribe = null;
_pruneTimer?.cancel();
_pruneTimer = null;
_retryTimer?.cancel();
_retryTimer = null;
_healthyTimer?.cancel();
_healthyTimer = null;
}); });
if (sessionState.status == SessionStatus.connected) { if (sessionState.status == SessionStatus.connected) {
@@ -63,6 +84,19 @@ class ChannelTypingNotifier extends Notifier<List<TypingEntry>> {
return []; return [];
} }
void _stop() {
_generation++;
_activity.clear();
_unsubscribe?.call();
_unsubscribe = null;
_pruneTimer?.cancel();
_pruneTimer = null;
_retryTimer?.cancel();
_retryTimer = null;
_healthyTimer?.cancel();
_healthyTimer = null;
}
Future<void> _subscribeLive(int generation, [int attempt = 0]) async { Future<void> _subscribeLive(int generation, [int attempt = 0]) async {
final session = ref.read(relaySessionProvider.notifier); final session = ref.read(relaySessionProvider.notifier);
var closed = false; var closed = false;

View File

@@ -167,9 +167,16 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// Re-fetch when the app returns to foreground so channels created on // Re-fetch when the app returns to foreground so channels created on
// another device while mobile was backgrounded appear immediately. // another device while mobile was backgrounded appear immediately.
ref.listen(appLifecycleProvider, (prev, next) { ref.listen(appLifecycleProvider, (prev, next) {
if (next == AppLifecycleState.resumed) { if (next != AppLifecycleState.resumed) return;
refresh(); // Resume is published before the session hears it. Let the session
} // decide first: one that reconnects reconciles when it connects, so
// refreshing here too would fetch the same list twice.
scheduleMicrotask(() {
if (!ref.mounted) return;
if (ref.read(relaySessionProvider).status == SessionStatus.connected) {
refresh();
}
});
}); });
ref.onDispose(() { ref.onDispose(() {

View File

@@ -166,10 +166,17 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier {
unawaited(_unreadCatchUp); unawaited(_unreadCatchUp);
_backstopTimer?.cancel(); _backstopTimer?.cancel();
_backstopTimer = Timer.periodic( _backstopTimer = Timer.periodic(ChannelsNotifier._backstopInterval, (_) {
ChannelsNotifier._backstopInterval, // A backgrounded app polls nothing, and a reconnecting session
(_) => _backstopRefresh(), // reconciles when it connects. After an OS suspension the missed tick
); // fires before the app hears it has resumed, so check both.
if (_lifecycleRef.read(appLifecycleProvider) ==
AppLifecycleState.resumed &&
_lifecycleRef.read(relaySessionProvider).status ==
SessionStatus.connected) {
unawaited(_backstopRefresh());
}
});
} }
void _handleLiveChunkClosed(String chunkKey, int generation, String message) { void _handleLiveChunkClosed(String chunkKey, int generation, String message) {

View File

@@ -33,6 +33,9 @@ class _HistorySubscription {
final Completer<List<NostrEvent>> completer; final Completer<List<NostrEvent>> completer;
final Timer timeout; final Timer timeout;
/// The filter of a request held until the socket authenticates.
NostrFilter? heldFilter;
_HistorySubscription({required this.completer, required this.timeout}); _HistorySubscription({required this.completer, required this.timeout});
} }
@@ -328,15 +331,22 @@ class RelaySessionNotifier extends Notifier<SessionState> {
TimeoutException('Relay history request timed out after $timeout'), TimeoutException('Relay history request timed out after $timeout'),
); );
} }
_sendClose(subId); if (sub?.heldFilter == null) _sendClose(subId);
}); });
_historySubscriptions[subId] = _HistorySubscription( final subscription = _HistorySubscription(
completer: completer, completer: completer,
timeout: timer, timeout: timer,
); );
_historySubscriptions[subId] = subscription;
_sendReq(subId, filter); // The relay rejects a REQ sent before NIP-42 AUTH completes. Hold it for
// the authenticated connection instead of paying a rejection and retry.
if (_socketConnected) {
_sendReq(subId, filter);
} else {
subscription.heldFilter = filter;
}
return completer.future; return completer.future;
} }
@@ -385,7 +395,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
readyCompleter: readyCompleter, readyCompleter: readyCompleter,
); );
_sendReq(subId, filter); // Before AUTH the relay closes it as auth-required, which is terminal.
// The replay on connect sends it instead.
if (_socketConnected) _sendReq(subId, filter);
try { try {
await readyCompleter.future.timeout( await readyCompleter.future.timeout(
@@ -573,6 +585,8 @@ class RelaySessionNotifier extends Notifier<SessionState> {
if (_disposed || _ageRestricted) return; if (_disposed || _ageRestricted) return;
final generation = ++_connectionGeneration; final generation = ++_connectionGeneration;
// A replacement socket is unauthenticated until its AUTH completes.
_socketConnected = false;
state = SessionState( state = SessionState(
status: _hasConnectedOnce status: _hasConnectedOnce
? SessionStatus.reconnecting ? SessionStatus.reconnecting
@@ -668,6 +682,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
Future<void> _replayLiveSubscriptions(int generation) async { Future<void> _replayLiveSubscriptions(int generation) async {
if (_rateLimitGate.isActive) await _rateLimitGate.wait(); if (_rateLimitGate.isActive) await _rateLimitGate.wait();
if (!_isActiveConnection(generation)) return; if (!_isActiveConnection(generation)) return;
_sendHeldHistory();
final entries = _liveSubscriptions.entries.toList(); final entries = _liveSubscriptions.entries.toList();
final visibleChannelId = _visibleChannelsByOwner.isEmpty final visibleChannelId = _visibleChannelsByOwner.isEmpty
@@ -1022,6 +1037,16 @@ class RelaySessionNotifier extends Notifier<SessionState> {
_socket?.send(['REQ', subId, filter.toJson()]); _socket?.send(['REQ', subId, filter.toJson()]);
} }
void _sendHeldHistory() {
for (final MapEntry(key: subId, value: sub)
in _historySubscriptions.entries) {
final filter = sub.heldFilter;
if (filter == null) continue;
sub.heldFilter = null;
_sendReq(subId, filter);
}
}
void _sendClose(String subId) { void _sendClose(String subId) {
_socket?.send(['CLOSE', subId]); _socket?.send(['CLOSE', subId]);
} }

View File

@@ -1,4 +1,5 @@
import 'package:buzz/features/channels/channel_messages_provider.dart'; import 'package:buzz/features/channels/channel_messages_provider.dart';
import 'package:buzz/features/channels/channel_typing_provider.dart';
import 'package:buzz/features/channels/pending_local_messages_provider.dart'; import 'package:buzz/features/channels/pending_local_messages_provider.dart';
import 'package:buzz/features/channels/send_message_provider.dart'; import 'package:buzz/features/channels/send_message_provider.dart';
import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/relay/relay.dart';
@@ -12,7 +13,7 @@ const _channel = '11111111-1111-4111-8111-111111111111';
void main() { void main() {
test( test(
'a channel loads, follows live messages, settles sends and recovers', 'a channel loads, follows, settles sends, recovers and detaches once left',
() { () {
fakeAsync((async) { fakeAsync((async) {
final relay = FakeRelay(); final relay = FakeRelay();
@@ -27,9 +28,11 @@ void main() {
); );
relay.seed([message('first', now - 20), message('second', now - 10)]); relay.seed([message('first', now - 20), message('second', now - 10)]);
// The relay admits the live REQ only after its access checks.
final admitted = relay.holdReq();
final container = ProviderContainer(overrides: relay.overrides); final container = ProviderContainer(overrides: relay.overrides);
addTearDown(container.dispose); addTearDown(container.dispose);
final messages = container.listen( var messages = container.listen(
channelMessagesProvider(_channel), channelMessagesProvider(_channel),
(_, _) {}, (_, _) {},
); );
@@ -60,7 +63,13 @@ void main() {
container.read(relaySessionProvider).status, container.read(relaySessionProvider).status,
SessionStatus.connected, SessionStatus.connected,
); );
expect(timeline(), ['first', 'second']); // History waits for the live EOSE, so a message committed before the
// relay admits the REQ is read, however long ago it was signed.
expect(relay.queries, isEmpty);
relay.seed([message('committed as it opened', now - 60)]);
admitted.complete();
settle();
expect(timeline(), ['committed as it opened', 'first', 'second']);
expect(relay.queries, hasLength(1), reason: 'read the channel window'); expect(relay.queries, hasLength(1), reason: 'read the channel window');
expect( expect(
relay.reqs.where((req) => (req[1] as String).startsWith('h-')), relay.reqs.where((req) => (req[1] as String).startsWith('h-')),
@@ -123,6 +132,49 @@ void main() {
settle(); settle();
expect(timeline(), contains('after reconnect')); expect(timeline(), contains('after reconnect'));
expect(timeline().where((text) => text == 'hello'), hasLength(1)); expect(timeline().where((text) => text == 'hello'), hasLength(1));
// A chat left for a while stops following live, typing included;
// returning shows the cached rows at once and catches up on what
// arrived meanwhile.
bool follows({required bool typing}) => relay.liveFilters.any(
(filters) => filters.any(
(filter) =>
((filter['#h'] as List?)?.contains(_channel) ?? false) &&
(filter['kinds'] as List).contains(EventKind.typingIndicator) ==
typing,
),
);
bool followsChannel() =>
follows(typing: false) && follows(typing: true);
bool followsNothing() =>
!follows(typing: false) && !follows(typing: true);
var typing = container.listen(
channelTypingProvider(_channel),
(_, _) {},
);
settle();
expect(followsChannel(), isTrue);
messages.close();
typing.close();
async.elapse(const Duration(seconds: 29));
expect(followsChannel(), isTrue, reason: 'a brief cover keeps it');
async.elapse(const Duration(seconds: 2));
expect(followsNothing(), isTrue);
// A reconnect while it's hidden must not strand it detached.
relay.drop();
async.elapse(const Duration(seconds: 2));
expect(followsNothing(), isTrue);
relay.deliver(message('while away', liveSince() + 1));
messages = container.listen(
channelMessagesProvider(_channel),
(_, _) {},
);
typing = container.listen(channelTypingProvider(_channel), (_, _) {});
expect(timeline(), contains('after reconnect'));
settle();
expect(followsChannel(), isTrue);
expect(timeline(), contains('while away'));
expect(timeline().where((text) => text == 'hello'), hasLength(1));
}); });
}, },
); );

View File

@@ -0,0 +1,119 @@
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/fake_relay.dart';
const _channel = '11111111-1111-4111-8111-111111111111';
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
test(
'the channel list reconciles once per resume and stays quiet while away',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
fakeAsync((async) {
var now = DateTime(2026, 10, 20, 12);
final relay = FakeRelay(now: () => now);
relay.seed([
relay.sign(
kind: 39002,
tags: [
['d', _channel],
['p', relay.pubkey],
],
),
relay.sign(
kind: 39000,
tags: [
['d', _channel],
['name', 'general'],
['t', 'stream'],
['public'],
],
),
]);
final container = ProviderContainer(
overrides: [
...relay.overrides,
savedPrefsProvider.overrideWithValue(prefs),
],
);
addTearDown(container.dispose);
final channels = container.listen(channelsProvider, (_, _) {});
// A membership read is paginated; count each read's first page.
int membershipReads() => relay.queries
.expand((filters) => filters)
.where(
(f) =>
f.containsKey('#p') &&
f['kinds'].contains(39002) &&
f['until'] == null,
)
.length;
void lifecycle(AppLifecycleState state) =>
binding.handleAppLifecycleStateChanged(state);
void settle() => async.elapse(const Duration(milliseconds: 50));
settle();
expect(channels.read().requireValue.single.name, 'general');
expect(membershipReads(), 1);
// Backgrounded just before a backstop tick, while the session is
// still connected through its grace period: the 60s backstop sends
// nothing until the app returns.
async.elapse(const Duration(seconds: 58));
expect(membershipReads(), 1);
lifecycle(AppLifecycleState.inactive);
lifecycle(AppLifecycleState.hidden);
lifecycle(AppLifecycleState.paused);
async.elapse(const Duration(minutes: 3));
expect(
container.read(relaySessionProvider).status,
isNot(SessionStatus.connected),
);
expect(membershipReads(), 1);
lifecycle(AppLifecycleState.hidden);
lifecycle(AppLifecycleState.inactive);
lifecycle(AppLifecycleState.resumed);
settle();
expect(
container.read(relaySessionProvider).status,
SessionStatus.connected,
);
expect(membershipReads(), 2);
// The OS suspends the app, so the session's pause timer never fires
// and it still believes it is connected when the app resumes. It
// reconnects; the list reconciles once, after AUTH, not before too.
lifecycle(AppLifecycleState.inactive);
lifecycle(AppLifecycleState.hidden);
lifecycle(AppLifecycleState.paused);
now = now.add(const Duration(minutes: 10));
final auth = relay.holdAuth();
lifecycle(AppLifecycleState.hidden);
lifecycle(AppLifecycleState.inactive);
lifecycle(AppLifecycleState.resumed);
settle();
expect(membershipReads(), 2);
auth.complete();
settle();
expect(
container.read(relaySessionProvider).status,
SessionStatus.connected,
);
expect(membershipReads(), 3);
expect(relay.reqsBeforeAuth, isEmpty);
expect(channels.read().requireValue.single.name, 'general');
});
},
);
}

View File

@@ -22,7 +22,9 @@ import 'package:nostr/nostr.dart' as nostr;
/// recorded and fails the test at teardown, because app code often catches /// recorded and fails the test at teardown, because app code often catches
/// relay errors and falls back. /// relay errors and falls back.
class FakeRelay { class FakeRelay {
FakeRelay() { /// [now] is the session's wall clock, so a test can model OS suspension:
/// time passes while no timer runs.
FakeRelay({DateTime Function()? now}) : _now = now {
addTearDown(() { addTearDown(() {
expect(unanswerable, isEmpty, reason: 'requests FakeRelay cannot answer'); expect(unanswerable, isEmpty, reason: 'requests FakeRelay cannot answer');
}); });
@@ -38,12 +40,16 @@ class FakeRelay {
final _rejections = <({bool Function(NostrEvent) match, String message})>[]; final _rejections = <({bool Function(NostrEvent) match, String message})>[];
Completer<void>? _okHold; Completer<void>? _okHold;
Completer<void>? _echoHold; Completer<void>? _echoHold;
Completer<void>? _reqHold;
Completer<void>? _authHold;
final DateTime Function()? _now;
String get pubkey => keys.public; String get pubkey => keys.public;
late final session = RelaySessionNotifier( late final session = RelaySessionNotifier(
socketFactory: _openSocket, socketFactory: _openSocket,
httpClient: MockClient(_handleHttp), httpClient: MockClient(_handleHttp),
now: _now,
); );
List<Override> get overrides => [ List<Override> get overrides => [
@@ -92,6 +98,22 @@ class FakeRelay {
/// The relay dispatches after commit, so echo and OK have no fixed order. /// The relay dispatches after commit, so echo and OK have no fixed order.
Completer<void> holdEcho() => _echoHold = Completer<void>(); Completer<void> holdEcho() => _echoHold = Completer<void>();
/// Holds new REQs until the completer completes, as a relay's access checks
/// would: they read stored events, start live fan-out and reach EOSE only
/// once admitted. Events committed meanwhile reach them only through replay.
Completer<void> holdReq() => _reqHold = Completer<void>();
/// Keeps the next connection unauthenticated until the completer completes.
/// Like the relay, it answers a REQ sent before AUTH with CLOSED.
Completer<void> holdAuth() => _authHold = Completer<void>();
/// REQs the relay rejected because they arrived before AUTH.
final reqsBeforeAuth = <List<dynamic>>[];
/// Filters of the live subscriptions open on the current connection.
Iterable<List<Map<String, dynamic>>> get liveFilters =>
sockets.isEmpty ? const [] : sockets.last._live.values;
/// Drops the current connection, as a network loss would. Its live /// Drops the current connection, as a network loss would. Its live
/// subscriptions and queued replies die with it; stored history survives. /// subscriptions and queued replies die with it; stored history survives.
void drop([Object? error]) => sockets.last.drop(error); void drop([Object? error]) => sockets.last.drop(error);
@@ -116,18 +138,35 @@ class FakeRelay {
void _receive(FakeRelaySocket socket, List<dynamic> message) { void _receive(FakeRelaySocket socket, List<dynamic> message) {
switch (message.first) { switch (message.first) {
case 'REQ': case 'REQ':
reqs.add(message);
final subId = message[1] as String; final subId = message[1] as String;
if (!socket._authenticated) {
reqsBeforeAuth.add(message);
scheduleMicrotask(
() => socket._reply([
'CLOSED',
subId,
'auth-required: not authenticated',
]),
);
return;
}
reqs.add(message);
final filters = message.skip(2).cast<Map<String, dynamic>>().toList(); final filters = message.skip(2).cast<Map<String, dynamic>>().toList();
final history = _query(filters); void admit() {
if (history == null) return; if (!socket._authenticated) return;
socket._live[subId] = filters; final history = _query(filters);
scheduleMicrotask(() { if (history == null) return;
for (final event in history) { socket._live[subId] = filters;
socket._reply(['EVENT', subId, event.toJson()]); scheduleMicrotask(() {
} for (final event in history) {
socket._reply(['EOSE', subId]); socket._reply(['EVENT', subId, event.toJson()]);
}); }
socket._reply(['EOSE', subId]);
});
}
final hold = _reqHold;
hold == null ? admit() : hold.future.then((_) => admit());
case 'CLOSE': case 'CLOSE':
socket._live.remove(message[1]); socket._live.remove(message[1]);
case 'EVENT': case 'EVENT':
@@ -224,6 +263,13 @@ class FakeRelay {
final until = filter['until'] as int?; final until = filter['until'] as int?;
if (since != null && event.createdAt < since) return false; if (since != null && event.createdAt < since) return false;
if (until != null && event.createdAt > until) return false; if (until != null && event.createdAt > until) return false;
// Buzz pagination: strictly after the cursor in `created_at DESC, id ASC`.
final beforeId = filter['before_id'] as String?;
if (beforeId != null &&
event.createdAt == until &&
event.id.compareTo(beforeId) <= 0) {
return false;
}
for (final MapEntry(:key, :value) in filter.entries) { for (final MapEntry(:key, :value) in filter.entries) {
if (!key.startsWith('#')) continue; if (!key.startsWith('#')) continue;
final name = key.substring(1); final name = key.substring(1);
@@ -327,10 +373,16 @@ class FakeRelaySocket extends RelaySocket {
final void Function(Object? error) _onDisconnected; final void Function(Object? error) _onDisconnected;
final _live = <String, List<Map<String, dynamic>>>{}; final _live = <String, List<Map<String, dynamic>>>{};
bool _open = false; bool _open = false;
bool _authenticated = false;
@override @override
Future<void> connect() async { Future<void> connect() async {
_open = true; _open = true;
final hold = _relay._authHold;
_relay._authHold = null;
if (hold != null) await hold.future;
if (!_open) return;
_authenticated = true;
_onConnected(); _onConnected();
} }
@@ -345,6 +397,7 @@ class FakeRelaySocket extends RelaySocket {
@override @override
void dispose() { void dispose() {
_open = false; _open = false;
_authenticated = false;
_live.clear(); _live.clear();
} }
@@ -359,7 +412,15 @@ class FakeRelaySocket extends RelaySocket {
} }
} }
const _filterKeys = {'ids', 'authors', 'kinds', 'since', 'until', 'limit'}; const _filterKeys = {
'ids',
'authors',
'kinds',
'since',
'until',
'limit',
'before_id',
};
const _windowKeys = { const _windowKeys = {
'top_level', 'top_level',
'include_summaries', 'include_summaries',

View File

@@ -815,7 +815,7 @@ void main() {
}); });
test( test(
'resume reconnects a stale connected session after a long pause', 'resume reconnects a stale session and holds requests until AUTH',
() async { () async {
final sockets = <_ControlledRelaySocket>[]; final sockets = <_ControlledRelaySocket>[];
final keychain = nostr.Keys.generate(); final keychain = nostr.Keys.generate();
@@ -868,6 +868,22 @@ void main() {
expect(sockets, hasLength(2)); expect(sockets, hasLength(2));
expect(sockets.first.disposeCalls, 1); expect(sockets.first.disposeCalls, 1);
expect(session.state.status, SessionStatus.reconnecting); expect(session.state.status, SessionStatus.reconnecting);
// The relay rejects a REQ sent before AUTH, so the session holds it.
final history = session.fetchHistory(
NostrFilter(kinds: const [0], authors: [keychain.public]),
);
unawaited(session.subscribe(NostrFilter(kinds: const [9]), (_) {}));
await Future<void>.delayed(Duration.zero);
expect(sockets.last.sent, isEmpty);
sockets.last.connectSuccessfully();
final reqs = sockets.last.sent;
expect(reqs.map((message) => message.first), ['REQ', 'REQ']);
final historyReq = reqs.singleWhere(
(message) => (message[2] as Map)['kinds'].contains(0),
);
session.debugHandleSocketMessageForTest(['EOSE', historyReq[1]]);
expect(await history, isEmpty);
}, },
); );
@@ -1961,6 +1977,7 @@ class _ControlledRelaySocket extends RelaySocket {
final void Function() _connected; final void Function() _connected;
final void Function(Object? error) _disconnected; final void Function(Object? error) _disconnected;
int disposeCalls = 0; int disposeCalls = 0;
final sent = <List<dynamic>>[];
_ControlledRelaySocket({ _ControlledRelaySocket({
required super.wsUrl, required super.wsUrl,
@@ -1974,6 +1991,9 @@ class _ControlledRelaySocket extends RelaySocket {
@override @override
Future<void> connect() async {} Future<void> connect() async {}
@override
void send(List<dynamic> payload) => sent.add(payload);
@override @override
void dispose() { void dispose() {
disposeCalls++; disposeCalls++;