mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +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>
609 lines
20 KiB
Dart
609 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:isolate';
|
|
|
|
import 'package:buzz/shared/push/push_presentation_cache.dart';
|
|
import 'package:buzz/shared/push/push_presentation_export_recovery.dart';
|
|
import 'package:buzz/shared/relay/nostr_models.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
const _channel = MethodChannel('buzz/push');
|
|
const _secret =
|
|
'0000000000000000000000000000000000000000000000000000000000000001';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
final calls = <MethodCall>[];
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
|
|
setUp(() {
|
|
calls.clear();
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
pushPresentationCacheError.value = null;
|
|
pushPresentationExportError.value = null;
|
|
messenger.setMockMethodCallHandler(_channel, (call) async {
|
|
calls.add(call);
|
|
return null;
|
|
});
|
|
});
|
|
tearDown(() {
|
|
messenger.setMockMethodCallHandler(_channel, null);
|
|
debugDefaultTargetPlatformOverride = null;
|
|
pushPresentationCacheError.value = null;
|
|
pushPresentationExportError.value = null;
|
|
});
|
|
|
|
for (final profiles in [true, false]) {
|
|
for (final failedChunk in [0, 1]) {
|
|
test(
|
|
'native chunk transient failure retries ${profiles ? "profiles" : "channels"} chunk $failedChunk in FIFO slot',
|
|
() async {
|
|
final limit = profiles ? 256 : 512;
|
|
final observations = ReceivePort();
|
|
addTearDown(observations.close);
|
|
var workerReads = 0;
|
|
final caller = Isolate.current.debugName;
|
|
observations.listen((name) {
|
|
if (name != caller) workerReads++;
|
|
});
|
|
final events = [
|
|
for (var i = 0; i <= limit; i++)
|
|
_signed(
|
|
profiles ? 0 : 39000,
|
|
100,
|
|
channelID: 'channel-$i',
|
|
secretKey: profiles
|
|
? (i + 1).toRadixString(16).padLeft(64, '0')
|
|
: _secret,
|
|
),
|
|
];
|
|
events[0] = _ObservedVerificationEvent(
|
|
events[0],
|
|
observations.sendPort,
|
|
);
|
|
final memberships = profiles
|
|
? <NostrEvent>[]
|
|
: [
|
|
for (var i = 0; i <= limit; i++)
|
|
_signed(39002, 100, channelID: 'channel-$i'),
|
|
];
|
|
var failed = false;
|
|
final successfulChunks = <Map>[];
|
|
messenger.setMockMethodCallHandler(_channel, (call) async {
|
|
calls.add(call);
|
|
final args = call.arguments as Map;
|
|
if (args['communityId'] == 'following-community') return null;
|
|
final chunk =
|
|
(args[profiles ? 'events' : 'metadataEvents'] as List).length ==
|
|
limit
|
|
? 0
|
|
: 1;
|
|
if (chunk == failedChunk && !failed) {
|
|
failed = true;
|
|
throw PlatformException(
|
|
code: profiles
|
|
? 'profile_cache_failed'
|
|
: 'channel_cache_failed',
|
|
);
|
|
}
|
|
successfulChunks.add(args);
|
|
return null;
|
|
});
|
|
final exported = PushPresentationExportRecovery().export(
|
|
() => profiles
|
|
? cacheBuzzPushProfileEvents('original-community', events)
|
|
: cacheBuzzPushChannelEvents(
|
|
'original-community',
|
|
events,
|
|
memberships,
|
|
),
|
|
);
|
|
final following = cacheBuzzPushProfileEvents('following-community', [
|
|
_signed(0, 200),
|
|
]);
|
|
expect(await exported, isTrue);
|
|
await following;
|
|
expect(calls.map((call) => call.arguments['communityId']), [
|
|
'original-community',
|
|
'original-community',
|
|
'original-community',
|
|
'following-community',
|
|
]);
|
|
expect(
|
|
calls[failedChunk + 1].arguments,
|
|
equals(calls[failedChunk].arguments),
|
|
reason: 'retry must reuse the exact failed verified chunk',
|
|
);
|
|
expect(successfulChunks, hasLength(2));
|
|
expect([
|
|
for (final chunk in successfulChunks)
|
|
...chunk[profiles ? 'events' : 'metadataEvents'] as List,
|
|
], unorderedEquals(events.map((event) => event.toJson())));
|
|
if (!profiles) {
|
|
expect([
|
|
for (final chunk in successfulChunks)
|
|
...chunk['membershipEvents'] as List,
|
|
], unorderedEquals(memberships.map((event) => event.toJson())));
|
|
}
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(
|
|
workerReads,
|
|
1,
|
|
reason: 'native retries must not repeat signature verification',
|
|
);
|
|
expect(pushPresentationExportError.value, isNull);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
for (final profiles in [true, false]) {
|
|
test(
|
|
'permanent first ${profiles ? "profile" : "channel"} chunk failure exhausts retries and stops export',
|
|
() async {
|
|
final limit = profiles ? 256 : 512;
|
|
final events = [
|
|
for (var i = 0; i <= limit; i++)
|
|
_signed(
|
|
profiles ? 0 : 39000,
|
|
100,
|
|
channelID: 'channel-$i',
|
|
secretKey: profiles
|
|
? (i + 1).toRadixString(16).padLeft(64, '0')
|
|
: _secret,
|
|
),
|
|
];
|
|
final memberships = profiles
|
|
? <NostrEvent>[]
|
|
: [
|
|
for (var i = 0; i <= limit; i++)
|
|
_signed(39002, 100, channelID: 'channel-$i'),
|
|
];
|
|
messenger.setMockMethodCallHandler(_channel, (call) async {
|
|
calls.add(call);
|
|
if (call.arguments['communityId'] == 'failed-community') {
|
|
throw PlatformException(
|
|
code: profiles ? 'profile_cache_failed' : 'channel_cache_failed',
|
|
);
|
|
}
|
|
return null;
|
|
});
|
|
final recovery = PushPresentationExportRecovery();
|
|
final succeeded = await recovery.export(
|
|
() => profiles
|
|
? cacheBuzzPushProfileEvents('failed-community', events)
|
|
: cacheBuzzPushChannelEvents(
|
|
'failed-community',
|
|
events,
|
|
memberships,
|
|
),
|
|
);
|
|
expect(succeeded, isFalse);
|
|
expect(
|
|
calls,
|
|
hasLength(6),
|
|
reason: 'initial attempt plus five retries, with no later chunk',
|
|
);
|
|
expect(
|
|
pushPresentationCacheError.value,
|
|
contains(profiles ? 'profile_cache_failed' : 'channel_cache_failed'),
|
|
);
|
|
final terminal = pushPresentationExportError.value;
|
|
expect(
|
|
terminal,
|
|
contains(profiles ? 'profile_cache_failed' : 'channel_cache_failed'),
|
|
);
|
|
await cacheBuzzPushProfileEvents('following-community', [
|
|
_signed(0, 200),
|
|
]);
|
|
expect(calls, hasLength(7));
|
|
for (final retry in calls.take(6)) {
|
|
expect(retry.arguments, equals(calls.first.arguments));
|
|
}
|
|
expect(calls.last.arguments['communityId'], 'following-community');
|
|
expect(pushPresentationExportError.value, terminal);
|
|
},
|
|
);
|
|
}
|
|
|
|
test('native retry budget is shared across all chunks', () async {
|
|
final events = [
|
|
for (var i = 1; i <= 257; i++)
|
|
_signed(0, 100, secretKey: i.toRadixString(16).padLeft(64, '0')),
|
|
];
|
|
messenger.setMockMethodCallHandler(_channel, (call) async {
|
|
calls.add(call);
|
|
final rows = call.arguments['events'] as List;
|
|
if (calls.length == 1 || rows.length == 1) {
|
|
throw PlatformException(code: 'profile_cache_failed');
|
|
}
|
|
return null;
|
|
});
|
|
expect(
|
|
await PushPresentationExportRecovery().export(
|
|
() => cacheBuzzPushProfileEvents('community', events),
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(calls, hasLength(7));
|
|
expect(calls.map((call) => (call.arguments['events'] as List).length), [
|
|
256,
|
|
256,
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
]);
|
|
expect(pushPresentationExportError.value, contains('profile_cache_failed'));
|
|
});
|
|
|
|
test(
|
|
'native argument errors are terminal without persistence retries',
|
|
() async {
|
|
messenger.setMockMethodCallHandler(_channel, (call) async {
|
|
calls.add(call);
|
|
throw PlatformException(code: 'invalid_arguments');
|
|
});
|
|
expect(
|
|
await PushPresentationExportRecovery().export(
|
|
() => cacheBuzzPushProfileEvents('community', [_signed(0, 100)]),
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(calls, hasLength(1));
|
|
expect(pushPresentationExportError.value, contains('invalid_arguments'));
|
|
},
|
|
);
|
|
|
|
test('unavailable native bridge remains an intentional no-op', () async {
|
|
messenger.setMockMethodCallHandler(_channel, (call) async {
|
|
calls.add(call);
|
|
throw MissingPluginException('synthetic non-Runner embedding');
|
|
});
|
|
expect(
|
|
await PushPresentationExportRecovery().export(
|
|
() => cacheBuzzPushProfileEvents('community', [_signed(0, 100)]),
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(calls, hasLength(1));
|
|
expect(pushPresentationCacheError.value, isNull);
|
|
expect(pushPresentationExportError.value, isNull);
|
|
});
|
|
|
|
for (final profiles in [true, false]) {
|
|
test(
|
|
'large ${profiles ? "profile" : "channel"} exports fit native bounds',
|
|
() async {
|
|
final limit = profiles ? 256 : 512;
|
|
final events = [
|
|
for (var i = 0; i <= limit; i++)
|
|
_signed(
|
|
profiles ? 0 : 39000,
|
|
100,
|
|
channelID: 'channel-$i',
|
|
secretKey: profiles
|
|
? (i + 1).toRadixString(16).padLeft(64, '0')
|
|
: _secret,
|
|
),
|
|
];
|
|
// Deliberately reverse roster order: each chunk must pair by channel ID.
|
|
final memberships = profiles
|
|
? <NostrEvent>[]
|
|
: [
|
|
for (var i = limit; i >= 0; i--)
|
|
_signed(39002, 100, channelID: 'channel-$i'),
|
|
];
|
|
final received = <dynamic>[];
|
|
final receivedMemberships = <dynamic>[];
|
|
messenger.setMockMethodCallHandler(_channel, (call) async {
|
|
final args = call.arguments as Map;
|
|
if (args['communityId'] == 'following-community') {
|
|
calls.add(call);
|
|
return null;
|
|
}
|
|
final metadata = args[profiles ? 'events' : 'metadataEvents'] as List;
|
|
final rosters = profiles
|
|
? <dynamic>[]
|
|
: args['membershipEvents'] as List;
|
|
if (metadata.length > limit || rosters.length > limit) {
|
|
throw PlatformException(code: 'invalid_arguments');
|
|
}
|
|
expect(args['communityId'], 'bounded-community');
|
|
if (!profiles) {
|
|
String id(dynamic event) =>
|
|
(event['tags'] as List).firstWhere((tag) => tag[0] == 'd')[1]
|
|
as String;
|
|
expect(rosters.map(id).toSet(), metadata.map(id).toSet());
|
|
}
|
|
received.addAll(metadata);
|
|
receivedMemberships.addAll(rosters);
|
|
calls.add(call);
|
|
return null;
|
|
});
|
|
final largeExport = profiles
|
|
? cacheBuzzPushProfileEvents('bounded-community', events)
|
|
: cacheBuzzPushChannelEvents(
|
|
'bounded-community',
|
|
events,
|
|
memberships,
|
|
);
|
|
final followingExport = cacheBuzzPushProfileEvents(
|
|
'following-community',
|
|
[_signed(0, 200)],
|
|
);
|
|
await Future.wait([largeExport, followingExport]);
|
|
expect(pushPresentationCacheError.value, isNull);
|
|
expect(calls.map((call) => call.arguments['communityId']), [
|
|
'bounded-community',
|
|
'bounded-community',
|
|
'following-community',
|
|
]);
|
|
expect(
|
|
received,
|
|
unorderedEquals(events.map((event) => event.toJson())),
|
|
);
|
|
expect(
|
|
receivedMemberships,
|
|
unorderedEquals(memberships.map((event) => event.toJson())),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
for (final kind in [0, 39000]) {
|
|
test(
|
|
'kind $kind verifies off the calling isolate before native export',
|
|
() async {
|
|
final verification = Completer<void>();
|
|
final observations = ReceivePort();
|
|
addTearDown(observations.close);
|
|
final callerIsolateName = Isolate.current.debugName;
|
|
observations.listen((name) {
|
|
if (name != callerIsolateName && !verification.isCompleted) {
|
|
verification.complete();
|
|
}
|
|
});
|
|
final event = _ObservedVerificationEvent(
|
|
_signed(kind, 100),
|
|
observations.sendPort,
|
|
);
|
|
var timerRan = false;
|
|
Timer.run(() => timerRan = true);
|
|
if (kind == 0) {
|
|
await cacheBuzzPushProfileEvents('community', [event]);
|
|
} else {
|
|
await cacheBuzzPushChannelEvents('community', [event], []);
|
|
}
|
|
// The signal originates from reading the real signature in the worker.
|
|
// Restoring synchronous verification cannot produce it. This is a
|
|
// liveness timeout, not a device-dependent elapsed-time performance gate.
|
|
await verification.future.timeout(const Duration(seconds: 5));
|
|
expect(timerRan, isTrue);
|
|
expect(calls.single.method, 'syncPushSnapshot');
|
|
expect(calls.single.arguments['communityId'], 'community');
|
|
final key = kind == 0 ? 'events' : 'metadataEvents';
|
|
expect(calls.single.arguments[key], [event.toJson()]);
|
|
},
|
|
);
|
|
}
|
|
|
|
test(
|
|
'profile export retains older valid candidates and signed payloads',
|
|
() async {
|
|
final older = _signed(0, 100);
|
|
final newestValid = _signed(0, 200);
|
|
await cacheBuzzPushProfileEvents('profiles-community', [
|
|
older,
|
|
_tampered(_signed(0, 300)),
|
|
newestValid,
|
|
_signed(39000, 400),
|
|
]);
|
|
expect(calls.single.arguments, {
|
|
'section': 'profiles',
|
|
'communityId': 'profiles-community',
|
|
'events': [newestValid.toJson()],
|
|
});
|
|
},
|
|
);
|
|
|
|
test(
|
|
'channel export preserves valid fallback and membership scope pairing',
|
|
() async {
|
|
final metadata = _signed(39000, 200);
|
|
final membership = _signed(39002, 200);
|
|
await cacheBuzzPushChannelEvents(
|
|
'channels-community',
|
|
[
|
|
_signed(39000, 100),
|
|
_tampered(_signed(39000, 300)),
|
|
metadata,
|
|
_signed(39000, 400, channelID: 'unpaired'),
|
|
],
|
|
[_tampered(_signed(39002, 300)), membership, _signed(39002, 100)],
|
|
);
|
|
expect(calls.single.arguments, {
|
|
'section': 'channels',
|
|
'communityId': 'channels-community',
|
|
'metadataEvents': [metadata.toJson()],
|
|
'membershipEvents': [membership.toJson()],
|
|
});
|
|
},
|
|
);
|
|
|
|
test(
|
|
'native handoff and completion retain FIFO across sections and communities',
|
|
() async {
|
|
final firstEntered = Completer<void>();
|
|
final releaseFirst = Completer<void>();
|
|
var firstReleased = false;
|
|
var overlappingHandoff = false;
|
|
messenger.setMockMethodCallHandler(_channel, (call) async {
|
|
calls.add(call);
|
|
if (calls.length == 1) {
|
|
firstEntered.complete();
|
|
await releaseFirst.future;
|
|
firstReleased = true;
|
|
} else {
|
|
overlappingHandoff |= !firstReleased;
|
|
}
|
|
return null;
|
|
});
|
|
final completed = <String>[];
|
|
final first = cacheBuzzPushProfileEvents('first', [
|
|
_signed(0, 100),
|
|
]).then((_) => completed.add('first'));
|
|
await firstEntered.future;
|
|
final second = cacheBuzzPushChannelEvents('second', [
|
|
_signed(39000, 100),
|
|
], []).then((_) => completed.add('second'));
|
|
final third = cacheBuzzPushProfileEvents('third', [
|
|
_signed(0, 200),
|
|
]).then((_) => completed.add('third'));
|
|
try {
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(completed, isEmpty);
|
|
expect(calls.length, 1);
|
|
} finally {
|
|
releaseFirst.complete();
|
|
await Future.wait([first, second, third]);
|
|
}
|
|
expect(overlappingHandoff, isFalse);
|
|
expect(completed, ['first', 'second', 'third']);
|
|
expect(calls.map((call) => call.arguments['communityId']), [
|
|
'first',
|
|
'second',
|
|
'third',
|
|
]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'saturation rejects explicitly and admitted exports drain before recovery',
|
|
() async {
|
|
final entered = Completer<void>();
|
|
final release = Completer<void>();
|
|
messenger.setMockMethodCallHandler(_channel, (call) async {
|
|
calls.add(call);
|
|
if (calls.length == 1) {
|
|
entered.complete();
|
|
await release.future;
|
|
}
|
|
return null;
|
|
});
|
|
final event = _signed(0, 100);
|
|
final admitted = [
|
|
cacheBuzzPushProfileEvents('0', [event]),
|
|
];
|
|
await entered.future;
|
|
try {
|
|
for (var i = 1; i < 8; i++) {
|
|
admitted.add(cacheBuzzPushProfileEvents('$i', [event]));
|
|
}
|
|
await expectLater(
|
|
cacheBuzzPushProfileEvents('rejected', [
|
|
event,
|
|
]).timeout(const Duration(seconds: 5)),
|
|
throwsStateError,
|
|
);
|
|
expect(calls.length, 1);
|
|
} finally {
|
|
release.complete();
|
|
await Future.wait(admitted);
|
|
}
|
|
expect(calls.map((call) => call.arguments['communityId']), [
|
|
for (var i = 0; i < 8; i++) '$i',
|
|
]);
|
|
await cacheBuzzPushProfileEvents('recovered', [event]);
|
|
expect(calls.last.arguments['communityId'], 'recovered');
|
|
expect(calls.length, 9);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'worker submission failure propagates and releases the next export',
|
|
() async {
|
|
final unsendable = ReceivePort();
|
|
addTearDown(unsendable.close);
|
|
final failed = cacheBuzzPushProfileEvents('failed', [
|
|
_UnsendableEvent(_signed(0, 100), unsendable),
|
|
]);
|
|
final failure = expectLater(failed, throwsArgumentError);
|
|
final next = cacheBuzzPushProfileEvents('next', [_signed(0, 200)]);
|
|
await failure;
|
|
await next.timeout(const Duration(seconds: 5));
|
|
expect(calls.length, 1);
|
|
expect(calls.single.arguments['communityId'], 'next');
|
|
},
|
|
);
|
|
}
|
|
|
|
NostrEvent _signed(
|
|
int kind,
|
|
int createdAt, {
|
|
String channelID = 'channel',
|
|
String secretKey = _secret,
|
|
}) => NostrEvent.fromJson(
|
|
nostr.Event.from(
|
|
kind: kind,
|
|
createdAt: createdAt,
|
|
content: kind == 0 ? '{"name":"Synthetic profile"}' : '',
|
|
tags: kind == 0
|
|
? []
|
|
: [
|
|
['d', channelID],
|
|
],
|
|
secretKey: secretKey,
|
|
).toMap(),
|
|
);
|
|
|
|
NostrEvent _tampered(NostrEvent event) => NostrEvent(
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
createdAt: event.createdAt,
|
|
kind: event.kind,
|
|
tags: event.tags,
|
|
content: 'Tampered',
|
|
sig: event.sig,
|
|
);
|
|
|
|
class _ObservedVerificationEvent extends NostrEvent {
|
|
final SendPort observations;
|
|
|
|
_ObservedVerificationEvent(NostrEvent event, this.observations)
|
|
: super(
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
createdAt: event.createdAt,
|
|
kind: event.kind,
|
|
tags: event.tags,
|
|
content: event.content,
|
|
sig: event.sig,
|
|
);
|
|
|
|
@override
|
|
String get sig {
|
|
observations.send(Isolate.current.debugName);
|
|
return super.sig;
|
|
}
|
|
}
|
|
|
|
class _UnsendableEvent extends NostrEvent {
|
|
final ReceivePort port;
|
|
|
|
_UnsendableEvent(NostrEvent event, this.port)
|
|
: super(
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
createdAt: event.createdAt,
|
|
kind: event.kind,
|
|
tags: event.tags,
|
|
content: event.content,
|
|
sig: event.sig,
|
|
);
|
|
}
|