mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
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>
172 lines
5.1 KiB
Dart
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));
|