mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## 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>
286 lines
10 KiB
Dart
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;
|
|
}
|