mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
On iOS, Buzz stores verified profile and channel information for the notification extension. The extension uses this cache to show sender and channel names. Loading profiles or channels also updates the cache. Cache updates currently check event signatures on the UI thread, even when the caller does not wait for the update. This can delay taps and screen updates. This change moves those checks to a background worker. Signature checks, event selection, community boundaries, and the order of native writes are preserved. The worker accepts up to eight batches. When it is busy, later profile requests stay coalesced before fetching; profiles already loaded remain available immediately. Overlapping channel updates trigger a fresh joined-channel cache snapshot after the current export finishes, without delaying or restarting the channel screen. Queue saturation gets up to five retries per operation. Pending updates get their own recovery attempt even if the preceding export fails. Recovery fetches joined-channel data once and retries cache admission without repeating relay reads. It does not fetch the open-channel directory. Verified payloads are split into native-sized writes (256 profiles or 512 paired channels), keeping the same export ordering. Native profile/channel write failures retry the same verified chunk while retaining the export's queue position. All chunks share five retries with 7.75 seconds total backoff; other native errors fail immediately. Terminal failures are recorded, and later successful writes do not hide them. Captured batches keep their original community. Pending channel refetches are retired on a community switch and refreshed when the user returns. The cache remains best effort; prolonged failures can still prevent delivery. Tests through the production APIs reduced UI delay from 27–31 ms to below 0.3 ms for four profiles, and from 59–60 ms to below 0.3 ms for four channel and membership pairs. The native payloads were identical. This does not measure overall screen speed. Validation: 2,225 mobile tests passed (one skipped), along with 18 focused export tests, 12 recovery tests, 39 native Swift tests, 35 focused profile tests, and static analysis. Production regressions cover 13-entity bursts, invalid newer events, community rebuilds, reentrant refreshes, and terminal failures. Regression tests fail when producer recovery controls, terminal-failure draining, or member-only recovery scope are removed. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
184 lines
6.1 KiB
Dart
184 lines
6.1 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:buzz/shared/community/community.dart';
|
|
import 'package:buzz/shared/community/community_provider.dart';
|
|
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
const bridge = MethodChannel('buzz/push');
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
|
|
test(
|
|
'pending profiles drain in bounded queries after delayed export',
|
|
() async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
final entered = Completer<void>();
|
|
final release = Completer<void>();
|
|
messenger.setMockMethodCallHandler(bridge, (_) async {
|
|
if (!entered.isCompleted) {
|
|
entered.complete();
|
|
await release.future;
|
|
}
|
|
return null;
|
|
});
|
|
addTearDown(() {
|
|
messenger.setMockMethodCallHandler(bridge, null);
|
|
debugDefaultTargetPlatformOverride = null;
|
|
});
|
|
final signed = NostrEvent.fromJson(
|
|
nostr.Event.from(
|
|
kind: 0,
|
|
createdAt: 1,
|
|
content: '{"name":"First"}',
|
|
tags: [],
|
|
secretKey: '1'.padLeft(64, '0'),
|
|
).toMap(),
|
|
);
|
|
final session = _ClampingSession(first: signed);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(() => session),
|
|
activeCommunityProvider.overrideWith(
|
|
(ref) async => Community(
|
|
id: 'original',
|
|
name: 'Synthetic',
|
|
relayUrl: 'https://example.invalid',
|
|
addedAt: DateTime(2026),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
await container.read(activeCommunityProvider.future);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
expect(await cache.preload([signed.pubkey]), isTrue);
|
|
await entered.future.timeout(const Duration(seconds: 5));
|
|
final keys = List.generate(1001, (i) => 'profile-$i');
|
|
var completed = false;
|
|
final pending = cache.preload(keys).then((result) {
|
|
completed = true;
|
|
return result;
|
|
});
|
|
final duplicate = cache.preload(keys);
|
|
try {
|
|
await Future<void>.delayed(const Duration(milliseconds: 60));
|
|
expect(session.filters, hasLength(1));
|
|
expect(completed, isFalse);
|
|
} finally {
|
|
release.complete();
|
|
}
|
|
expect(await pending, isTrue);
|
|
expect(await duplicate, isTrue);
|
|
expect(container.read(userCacheProvider).keys, containsAll(keys));
|
|
expect(session.filters.map((f) => f.authors!.length), [1, 1000, 1]);
|
|
expect(session.filters.map((f) => f.limit), [1, 1000, 1]);
|
|
expect(session.maxRunning, 1);
|
|
},
|
|
);
|
|
|
|
for (final preload in [true, false]) {
|
|
for (final outcome in ['success', 'failure', 'community change']) {
|
|
test(
|
|
'${preload ? "preload" : "refresh"} waits for final bounded query: $outcome',
|
|
() async {
|
|
final started = Completer<void>();
|
|
final release = Completer<void>();
|
|
final session = _ClampingSession(
|
|
beforeFetch: (index) async {
|
|
if (index != 1) return;
|
|
started.complete();
|
|
await release.future;
|
|
if (outcome == 'failure') throw StateError('relay unavailable');
|
|
},
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final keys = List.generate(1001, (i) => 'profile-$i');
|
|
bool? result;
|
|
final done = (preload ? cache.preload(keys) : cache.refresh(keys))
|
|
.then((value) => result = value);
|
|
await started.future.timeout(const Duration(seconds: 5));
|
|
expect(result, isNull);
|
|
expect(container.read(userCacheProvider), hasLength(1000));
|
|
if (outcome == 'community change') {
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://next.invalid');
|
|
container.read(userCacheProvider);
|
|
}
|
|
release.complete();
|
|
await done;
|
|
expect(result, outcome == 'success');
|
|
expect(session.filters.map((f) => f.authors!.length), [1000, 1]);
|
|
expect(session.maxRunning, 1);
|
|
expect(
|
|
container.read(userCacheProvider),
|
|
hasLength(
|
|
outcome == 'community change'
|
|
? 0
|
|
: outcome == 'failure'
|
|
? 1000
|
|
: 1001,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _ClampingSession extends RelaySessionNotifier {
|
|
_ClampingSession({this.first, this.beforeFetch});
|
|
final NostrEvent? first;
|
|
final Future<void> Function(int)? beforeFetch;
|
|
final filters = <NostrFilter>[];
|
|
int running = 0;
|
|
int maxRunning = 0;
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
final index = filters.length;
|
|
filters.add(filter);
|
|
running++;
|
|
if (running > maxRunning) maxRunning = running;
|
|
try {
|
|
await beforeFetch?.call(index);
|
|
return [
|
|
for (final key in filter.authors!.take(1000))
|
|
if (key == first?.pubkey)
|
|
first!
|
|
else
|
|
NostrEvent(
|
|
id: key,
|
|
pubkey: key,
|
|
createdAt: 1,
|
|
kind: 0,
|
|
tags: [],
|
|
content: jsonEncode({'name': key}),
|
|
sig: 'synthetic',
|
|
),
|
|
];
|
|
} finally {
|
|
running--;
|
|
}
|
|
}
|
|
}
|