Files
buzz/mobile/lib/features/channels/channels_provider_lifecycle.dart
Wes d090ac24bb 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>
2026-10-26 11:24:43 -06:00

286 lines
10 KiB
Dart

part of 'channels_provider.dart';
// Mirrors MAX_EXPLICIT_CHANNEL_VALUES in the relay REQ handler. A larger live
// set must use multiple subscriptions or the relay rejects the whole REQ.
const _maxLiveChannelsPerSubscription = 128;
/// Owns live channel subscription reconciliation and teardown.
///
/// Keeping this extension in a part preserves access to the notifier's private
/// lifecycle state while keeping `channels_provider.dart` below the mobile
/// file-size ratchet.
extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier {
/// Subscribe to live events in deterministic chunks that stay within the
/// relay's aggregate explicit-`#h` limit. Also starts a 60s WS backstop poll
/// to reconcile membership changes without downloading the global directory.
Future<void> _subscribeLive(
List<Channel> channels,
_ChannelRefreshFence fence,
) {
final channelIds = {
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel.id,
};
final relayBaseUrl = _lifecycleRef.read(relayConfigProvider).baseUrl;
_desiredLiveChannelIds = channelIds;
// A changed filter may be admitted, but an unchanged terminal rejection
// cannot recover just because a timer or manual refresh fetched it again.
// Drop obsolete keys so this quarantine stays bounded by desired chunks.
final desiredKeys = chunkChannelIdsForLiveSubscriptions(
channelIds,
).map(_liveChunkKey).toSet();
_terminallyClosedLiveChunks.retainAll(desiredKeys);
final subscriptionVersion = ++_subscriptionVersion;
final sync = _liveSubscriptionQueue.then(
(_) => _syncLiveSubscriptions(
relayBaseUrl,
subscriptionVersion,
channels,
fence,
),
);
_liveSubscriptionQueue = sync
.whenComplete(() {
// Reconcile even if a retired generation exits before its successor
// can run (for example, a disconnect while subscribe is pending).
if (_lifecycleRef.mounted) _removeUndesiredLiveChunks();
})
.catchError((Object error, StackTrace stack) {
if (error is! _StaleChannelRefresh) {
debugPrint(
'[ChannelsNotifier] live subscription sync failed: $error\n$stack',
);
}
});
_notifyUnreadReadinessChanged();
return _liveSubscriptionQueue;
}
Future<void> _syncLiveSubscriptions(
String relayBaseUrl,
int subscriptionVersion,
List<Channel> channels,
_ChannelRefreshFence fence,
) async {
if (!_lifecycleRef.mounted) return;
fence.ensureCurrent();
if (_lifecycleRef.read(relaySessionProvider).status !=
SessionStatus.connected ||
subscriptionVersion != _subscriptionVersion) {
return;
}
if (_subscriptionRelayBaseUrl != relayBaseUrl) {
_clearRetainedLiveChunks();
_subscriptionRelayBaseUrl = relayBaseUrl;
}
if (_lifecycleRef.read(relayConfigProvider).baseUrl != relayBaseUrl) return;
final desiredChunks = chunkChannelIdsForLiveSubscriptions(
_desiredLiveChannelIds,
);
// Keep existing coverage while replacements are installed. Cleanup runs
// when this generation settles, and retains old coverage on failures.
final session = _lifecycleRef.read(relaySessionProvider.notifier);
for (final chunk in desiredChunks) {
final chunkKey = _liveChunkKey(chunk);
if (_liveSubscriptionsByChunk.containsKey(chunkKey) ||
_terminallyClosedLiveChunks.contains(chunkKey)) {
continue;
}
if (_lifecycleRef.read(relaySessionProvider).status !=
SessionStatus.connected) {
return;
}
final generation = ++_nextLiveChunkGeneration;
final subscription = _LiveChunkSubscription(generation);
_liveSubscriptionsByChunk[chunkKey] = subscription;
try {
final unsubscribe = await session.subscribe(
NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {'#h': chunk},
limit: 0,
),
(event) {
// Superseded chunks can overlap during replacement or recovery.
// Deliver only the current scope's still-desired channels; duplicate
// events are already idempotent in the unread/timestamp stores.
if (!_lifecycleRef.mounted ||
_refreshCoordinator.currentScope() != fence.scope ||
!_desiredLiveChannelIds.contains(event.channelId)) {
return;
}
_handleLiveEvent(event);
},
onClosed: (message) =>
_handleLiveChunkClosed(chunkKey, generation, message),
);
subscription.unsubscribe = unsubscribe;
if (!_lifecycleRef.mounted || !fence.isCurrent) {
_liveSubscriptionsByChunk.remove(chunkKey);
unsubscribe();
if (!fence.isCurrent) throw const _StaleChannelRefresh();
return;
}
if (subscriptionVersion != _subscriptionVersion ||
_lifecycleRef.read(relaySessionProvider).status !=
SessionStatus.connected ||
_lifecycleRef.read(relayConfigProvider).baseUrl != relayBaseUrl ||
_subscriptionRelayBaseUrl != relayBaseUrl ||
!chunk.every(_desiredLiveChannelIds.contains)) {
_liveSubscriptionsByChunk.remove(chunkKey);
unsubscribe();
return;
}
if (_liveSubscriptionsByChunk[chunkKey] != subscription) {
unsubscribe();
continue;
}
} on _StaleChannelRefresh {
rethrow;
} catch (error) {
if (_liveSubscriptionsByChunk[chunkKey] == subscription) {
_liveSubscriptionsByChunk.remove(chunkKey);
}
if (!_lifecycleRef.mounted) return;
debugPrint(
'[ChannelsNotifier] live subscription failed for '
'${chunk.length} channels: $error',
);
}
}
if (!_lifecycleRef.mounted ||
_lifecycleRef.read(relaySessionProvider).status !=
SessionStatus.connected ||
subscriptionVersion != _subscriptionVersion) {
return;
}
fence.ensureCurrent();
_unreadCatchUp = _catchUpUnreadEvents(channels, fence, subscriptionVersion);
_notifyUnreadReadinessChanged();
unawaited(_unreadCatchUp);
_backstopTimer?.cancel();
_backstopTimer = Timer.periodic(ChannelsNotifier._backstopInterval, (_) {
// A backgrounded app polls nothing, and a reconnecting session
// 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) {
if (!_lifecycleRef.mounted) return;
final subscription = _liveSubscriptionsByChunk[chunkKey];
if (subscription == null || subscription.generation != generation) return;
_liveSubscriptionsByChunk.remove(chunkKey);
// RelaySession calls onClosed only for terminal admission failures. Its
// transient/rate-limit retries have their own backoff. Do not turn a
// terminal rejection into a fresh membership/history/REQ loop here.
if (chunkChannelIdsForLiveSubscriptions(
_desiredLiveChannelIds,
).any((chunk) => _liveChunkKey(chunk) == chunkKey)) {
_terminallyClosedLiveChunks.add(chunkKey);
}
debugPrint(
'[ChannelsNotifier] live subscription closed by relay: $message',
);
}
void _removeUndesiredLiveChunks() {
final desiredChunks = chunkChannelIdsForLiveSubscriptions(
_desiredLiveChannelIds,
);
final desiredKeys = desiredChunks.map(_liveChunkKey).toSet();
final retainedKeys = <String>{...desiredKeys};
final uncoveredIds = <String>{};
for (final chunk in desiredChunks) {
final key = _liveChunkKey(chunk);
if (!_liveSubscriptionsByChunk.containsKey(key)) {
uncoveredIds.addAll(chunk);
}
}
final obsolete = _liveSubscriptionsByChunk.entries
.where((entry) => !desiredKeys.contains(entry.key))
.toList();
while (uncoveredIds.isNotEmpty) {
MapEntry<String, _LiveChunkSubscription>? best;
var bestCoverage = 0;
for (final entry in obsolete) {
if (retainedKeys.contains(entry.key)) continue;
final coverage = entry.key
.split('\u0000')
.where(uncoveredIds.contains)
.length;
if (coverage > bestCoverage) {
best = entry;
bestCoverage = coverage;
}
}
if (best == null) break;
retainedKeys.add(best.key);
uncoveredIds.removeAll(best.key.split('\u0000'));
}
for (final entry in _liveSubscriptionsByChunk.entries.toList()) {
if (retainedKeys.contains(entry.key)) continue;
_liveSubscriptionsByChunk.remove(entry.key);
entry.value.unsubscribe?.call();
}
}
void _clearRetainedLiveChunks() {
for (final subscription in _liveSubscriptionsByChunk.values) {
subscription.unsubscribe?.call();
}
_liveSubscriptionsByChunk.clear();
}
void _clearLiveSubscriptions() {
_subscriptionVersion++;
_notifyUnreadReadinessChanged();
_desiredLiveChannelIds = const {};
_terminallyClosedLiveChunks.clear();
_clearRetainedLiveChunks();
_subscriptionRelayBaseUrl = null;
_backstopTimer?.cancel();
_backstopTimer = null;
}
}
/// Returns deterministic channel-ID chunks within the relay's live REQ cap.
List<List<String>> chunkChannelIdsForLiveSubscriptions(
Iterable<String> channelIds,
) {
final sortedIds = channelIds.toList()..sort();
return [
for (
var start = 0;
start < sortedIds.length;
start += _maxLiveChannelsPerSubscription
)
sortedIds.sublist(
start,
min(start + _maxLiveChannelsPerSubscription, sortedIds.length),
),
];
}
String _liveChunkKey(List<String> channelIds) => channelIds.join('\u0000');
class _LiveChunkSubscription {
_LiveChunkSubscription(this.generation);
final int generation;
void Function()? unsubscribe;
}