mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile): stop oversized read-state retry loop (#4595)
## Summary - stop retrying remote read-state publishes after the local replacement blob exceeds NIP-44's 65,535-byte plaintext limit - preserve every local read marker and leave existing relay state untouched rather than truncating remote state - keep incoming remote read-state available while suppressing further invalid publishes for the manager lifetime ## Why A repaired/reconnecting relay exposed a 1,404-context read-state on iOS. The app repeatedly serialized and attempted to encrypt that structurally oversized blob while reconnect catch-up work was running, saturating Flutter's debug UI isolate and making channel navigation take roughly ten seconds. This is intentionally fail-closed and behavior-preserving: local read behavior continues, but remote publishing pauses until the manager is recreated. No protocol or persisted-data format changes. ## Verification - `flutter test` — 1,093 passed, 1 skipped - `flutter analyze` — no issues - pre-push `mobile-test` and `branch-skew` hooks passed at `0b6423c5d4d583194f0bbe69662912133b9ae1ef` - independent review by Princess Donut: no blocking findings; compatibility-safe and correctly fail-closed Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -423,6 +423,16 @@ class ReadStateManager {
|
||||
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt);
|
||||
_persistLocalState();
|
||||
} catch (error) {
|
||||
if (_isOversizedReadStateError(error)) {
|
||||
_remoteUnsupported = true;
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = null;
|
||||
debugPrint(
|
||||
'[ReadStateManager] remote read-state sync disabled because the '
|
||||
'local state exceeds the NIP-44 plaintext limit.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_isPermanentReadStateRemoteError(error)) {
|
||||
_remoteUnsupported = true;
|
||||
_debounceTimer?.cancel();
|
||||
@@ -526,6 +536,12 @@ class ReadStateManager {
|
||||
bool _isPlausibleCreatedAt(int createdAt) =>
|
||||
createdAt <= currentUnixSeconds() + readStateMaxClockDriftSeconds;
|
||||
|
||||
bool _isOversizedReadStateError(Object error) {
|
||||
final msg = error.toString().toLowerCase();
|
||||
return error is ArgumentError &&
|
||||
msg.contains('plaintext must be 1-65535 bytes');
|
||||
}
|
||||
|
||||
bool _isPermanentReadStateRemoteError(Object error) {
|
||||
// Relay rejections come back as `Exception("<message>")` from the
|
||||
// websocket OK handler. Pattern-match on the message text since we no
|
||||
|
||||
@@ -107,6 +107,41 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('disables remote sync after an oversized local blob', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final keychain = nostr.Keys.generate();
|
||||
final crypto = ReadStateCrypto.tryCreate(
|
||||
nsec: keychain.nsec,
|
||||
pubkey: keychain.public,
|
||||
)!;
|
||||
final relay = _FakeSignedEventRelay();
|
||||
final manager = ReadStateManager(
|
||||
pubkey: keychain.public,
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: null,
|
||||
signedEventRelay: relay,
|
||||
remoteEnabled: true,
|
||||
onChanged: () {},
|
||||
);
|
||||
|
||||
for (var index = 0; index < 1400; index++) {
|
||||
manager.markContextRead(
|
||||
'channel-${index.toString().padLeft(4, '0')}-${'x' * 48}',
|
||||
index + 1,
|
||||
);
|
||||
}
|
||||
await manager.flush();
|
||||
|
||||
manager.markContextRead('channel-new', 2000);
|
||||
await manager.flush();
|
||||
|
||||
expect(relay.submitCount, 0);
|
||||
expect(manager.getEffectiveTimestamp('channel-0000-${'x' * 48}'), 1);
|
||||
expect(manager.getEffectiveTimestamp('channel-new'), 2000);
|
||||
});
|
||||
|
||||
test('remote read-state rollback is ignored', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -171,6 +206,7 @@ NostrEvent _stubAckEvent() => const NostrEvent(
|
||||
|
||||
class _FakeSignedEventRelay implements SignedEventRelay {
|
||||
final Completer<_SubmittedEvent> submitted = Completer<_SubmittedEvent>();
|
||||
int submitCount = 0;
|
||||
|
||||
@override
|
||||
String? get pubkey => null;
|
||||
@@ -183,6 +219,7 @@ class _FakeSignedEventRelay implements SignedEventRelay {
|
||||
int? createdAt,
|
||||
void Function(NostrEvent event)? onSigned,
|
||||
}) async {
|
||||
submitCount++;
|
||||
submitted.complete(_SubmittedEvent(kind: kind, tags: tags));
|
||||
return _stubAckEvent();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user