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:
Wes
2026-08-04 12:37:20 -07:00
committed by GitHub
co-authored by Carl
parent e5efd04705
commit 7bee84da82
2 changed files with 53 additions and 0 deletions
@@ -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();
}