Files
buzz/mobile/test/features/channels/sidebar_sync_fixture.dart
Will Pfleger 309e34d80f fix(mobile): converge sidebar managers on relay head with resume re-read (#7806)
Mobile half of the sidebar stale-reader fix (desktop: #7805). It makes
the channel sections, sort, stars and mutes managers converge on the
latest relay head.

## Changes

- **Tie-break:** sections, stars and mutes compare `(created_at, id)`
with `id < cursor`, which matches the relay's `ORDER BY created_at DESC,
id ASC`. Sort already did this.
- **Coherent cursor:** `_publish()` records the signed event ID through
`SignedEventRelay.submit(onSigned:)` and advances
`(_lastRemoteCreatedAt, _lastRemoteEventId)` together. It only advances
when the pair is after the current cursor, so a newer head learned
during the await is kept. This applies to sections, stars and mutes.
- **Stars/mutes startup retry:** one serialized `_syncWithRelay()`
(`_syncInFlight`/`_syncAgain`) with 2s→30s backoff. It retries until the
history head is applied and the live subscription is settled. The
stars/mutes managers no longer re-subscribe on CLOSED:
`RelaySessionNotifier` owns retryable and rate-limited CLOSED, and a
terminal CLOSED is never re-sent. Reconnect works through session replay
and provider rebuild. Sections and sort keep their existing manager
CLOSED retry.
- **Stars/mutes durability:** storage `write()` returns the
`setString()` result. A decode or apply error, or a persist that returns
false or throws, fails the attempt. The accepted head only advances once
it is durable, so the same head is retried.
- **Stars/mutes pending fence:** each read snapshots `_localRevision`
and `_publishPending`. If a local edit or a publish happened while the
read was in flight, the result is discarded. Startup recovery retries
it; a resume read does not. Per-entry arbitration is unchanged.
- **Sections publish commits content with its cursor:** the submitted
blob is committed together with its cursor, so a same-second loser
applied during the OK wait can't strand the device. A local edit made
during the wait is kept.
- **Stars/mutes undecodable head:** an undecodable head is skipped
without holding startup open. Only a persist failure keeps the head
unapplied and retried.
- **Resume re-read:** all four providers listen to
`appLifecycleProvider`. On `resumed`, each manager runs one guarded
history read through its existing fetch/apply path. It is one-shot:
skipped when a local edit or publish is pending as it starts, not
retried on failure, and it starts no timers or poll loop. The result is
discarded if a mutation lands before it applies, and a disposed or
superseded manager (sort generation fence) never persists it.

## Residuals

- If the app stays in the foreground the whole time and a healthy socket
drops an EVENT, the missed head is picked up on the next resume or
reconnect.
- Sections: a live event can still overwrite a pending local edit. This
is pre-existing, and the resume read adds no new path to it.
- Sort: a greater remote timestamp wins, by policy.
- Stars/mutes: entries with equal `updatedAt` can still resolve
differently on different clients, under the existing merge policy.
- Stars/mutes: publish reads first and keeps the entry with the later
`updatedAt`, so a peer whose clock runs fast can override a pending
local entry. This rule already exists on main and matches desktop.
- Sections and sort persistence failures are pre-existing and not
redesigned here.

## Size

Production: 10 files, +495/−162. Tests: 3 files, +718. The new
`sidebar_sync_fixture.dart` runs over the real `RelaySessionNotifier`
seam and is shared by `channel_reader_recovery_test.dart`. Plus a
sections tie-break case.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-24 10:16:14 -06:00

172 lines
5.1 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
/// A scripted relay behind the real [RelaySessionNotifier], so REQ/EOSE,
/// CLOSED classification, event batching and OK all run production code.
/// Retains replaceable heads the way the relay does: `created_at DESC, id ASC`.
class SidebarRelay {
SidebarRelay() {
session.debugAttachSocketForTest(_ScriptedSocket(_onSend));
}
final keys = nostr.Keys.generate();
final session = ConnectedRelaySession();
late final signer = SignedEventRelay(session: session, nsec: keys.nsec);
final stored = <NostrEvent>[];
final published = <NostrEvent>[];
final reqs = <List<dynamic>>[];
final _liveSubIds = <String, String>{};
int historyFailures = 0;
Completer<void>? holdHistory;
Completer<void>? holdOk;
String? rejectLive;
bool withholdEose = false;
String get pubkey => keys.public;
NostrEvent event(String dTag, Object payload, int createdAt, {String? id}) =>
NostrEvent(
id: id ?? 'e$createdAt'.padRight(64, 'f'),
pubkey: pubkey,
createdAt: createdAt,
kind: EventKind.readState,
tags: [
['d', dTag],
],
content: nip44Encrypt(_key, jsonEncode(payload)),
sig: 'sig',
);
Object? decrypt(NostrEvent event) =>
jsonDecode(nip44Decrypt(_key, event.content));
late final _key = getConversationKey(
nostr.Nip19.decode(payload: keys.nsec).data,
keys.public,
);
List<List<dynamic>> reqsFor(String dTag, String prefix) => [
for (final req in reqs)
if ((req[1] as String).startsWith(prefix) && _dTagOf(req) == dTag) req,
];
void emit(NostrEvent event) => session.debugHandleMessage([
'EVENT',
_liveSubIds[event.getTagValue('d')],
event.toJson(),
]);
void closeLive(String dTag, String message) =>
session.debugHandleMessage(['CLOSED', _liveSubIds[dTag], message]);
void _onSend(List<dynamic> message) {
if (message.first == 'EVENT') {
final event = NostrEvent.fromJson(message[1] as Map<String, dynamic>);
published.add(event);
stored.add(event);
final ok = ['OK', event.id, true, ''];
final hold = holdOk;
hold == null
? scheduleMicrotask(() => _reply(ok))
: hold.future.then((_) => _reply(ok));
}
if (message.first != 'REQ') return;
reqs.add(message);
final subId = message[1] as String;
if (subId.startsWith('h-')) {
unawaited(_answerHistory(subId, _dTagOf(message)));
return;
}
_liveSubIds[_dTagOf(message)] = subId;
final rejection = rejectLive;
if (rejection == null && withholdEose) return;
scheduleMicrotask(
() => _reply(
rejection == null ? ['EOSE', subId] : ['CLOSED', subId, rejection],
),
);
}
Future<void> _answerHistory(String subId, String dTag) async {
await holdHistory?.future;
if (historyFailures > 0) {
historyFailures--;
return _reply(['CLOSED', subId, 'error: overloaded']);
}
final heads = stored.where((e) => e.getTagValue('d') == dTag).toList()
..sort(
(a, b) => a.createdAt != b.createdAt
? b.createdAt.compareTo(a.createdAt)
: a.id.compareTo(b.id),
);
if (heads.isNotEmpty) _reply(['EVENT', subId, heads.first.toJson()]);
_reply(['EOSE', subId]);
}
void _reply(List<dynamic> message) => session.debugHandleMessage(message);
static String _dTagOf(List<dynamic> req) =>
((req[2] as Map<String, dynamic>)['#d'] as List).single as String;
}
class ConnectedRelaySession extends RelaySessionNotifier {
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
}
class _ScriptedSocket extends RelaySocket {
_ScriptedSocket(this._send)
: super(
wsUrl: 'wss://relay.example',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (_) {},
);
final void Function(List<dynamic>) _send;
@override
void send(List<dynamic> payload) => _send(payload);
@override
void dispose() {}
}
/// Preferences whose next [failures] writes return false or throw [error].
class FlakyPrefs extends Fake implements SharedPreferences {
FlakyPrefs(this._inner);
final SharedPreferences _inner;
int failures = 0;
Object? error;
@override
String? getString(String key) => _inner.getString(key);
@override
Future<bool> setString(String key, String value) async {
if (failures == 0) return _inner.setString(key, value);
failures--;
if (error == null) return false;
throw error!;
}
}
Future<SharedPreferences> freshPrefs() async {
SharedPreferences.setMockInitialValues({});
return SharedPreferences.getInstance();
}
int nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
void fakeAsyncTest(String name, void Function(FakeAsync clock) body) =>
test(name, () => fakeAsync(body));