mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
On relays behind a CDN that gzips responses (the Builderlab relay does), mobile read state never synced. Every read-state snapshot fetch, and therefore every read-mark publish, failed with `FormatException: Unexpected extension byte`, so read marks made on the phone never reached other devices and each reconnect retried and failed again. The relay query transport deliberately leaves gzip bodies compressed so they can be inflated off the UI isolate (see `RelayHttpQueryClient`), but the snapshot path decoded the raw bytes with `utf8.decode`. It now uses `relayResponseText`, like the other query callers, for both the discovery and the snapshot responses. Heads-up for reviewers: with the snapshot working, a resume also pays for the full snapshot download on top of the existing read-state replay on the live subscription. A follow-up PR removes that replay. ### Validation - Added test: `inflates gzip discovery and snapshot bodies` serves both responses gzipped. Without the fix it fails with the same `Unexpected extension byte` error seen on the device. - Full mobile suite: all pass except the known stale `fullscreen_onboarding_test` case, which fails on main too. - iPhone 12 mini (Profile build, Builderlab community): before, every resume logged `snapshot failed` and `publish failed (journal retained)`. After, the snapshot on resume and the snapshot inside a publish both completed with no errors. - Automated app run on the same phone with this change: Home backgrounded past the reconnect grace period and resumed, then search and a large channel opened. No read-state errors logged. Read marks were not compared across devices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
117 lines
4.0 KiB
Dart
117 lines
4.0 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:isolate';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'nostr_models.dart';
|
|
|
|
/// Turns a successful relay query response into events.
|
|
typedef RelayQueryDecoder =
|
|
Future<List<NostrEvent>> Function(http.Response response);
|
|
|
|
/// Decodes a relay query response without blocking the UI isolate.
|
|
///
|
|
/// Inflating, UTF-8 decoding, JSON parsing, and event parsing all run in one
|
|
/// short-lived isolate, which hands the parsed events back by ownership
|
|
/// transfer instead of copying them. A per-response isolate keeps no state to
|
|
/// dispose and is the same shape as `parseProfileEventBatch`.
|
|
///
|
|
/// Throws the same [FormatException]s as [parseRelayQueryEvents].
|
|
Future<List<NostrEvent>> decodeRelayQueryResponse(http.Response response) =>
|
|
_decodeInIsolate(response.bodyBytes, response.headers['content-type']);
|
|
|
|
// Takes only the bytes and content type so the isolate closure cannot capture
|
|
// the response, its headers, or any session state.
|
|
Future<List<NostrEvent>> _decodeInIsolate(
|
|
Uint8List bytes,
|
|
String? contentType,
|
|
) => Isolate.run(() => parseRelayQueryEvents(bytes, contentType));
|
|
|
|
/// Parses the event array a relay `/query` or `/profiles/search` answers.
|
|
///
|
|
/// Throws a [FormatException] for a body that is not JSON, is not an array,
|
|
/// or holds an element that is not a well-formed event.
|
|
List<NostrEvent> parseRelayQueryEvents(Uint8List bytes, String? contentType) {
|
|
final decoded = jsonDecode(relayResponseText(bytes, contentType));
|
|
if (decoded is! List) {
|
|
throw const FormatException('relay returned malformed query response');
|
|
}
|
|
try {
|
|
return [
|
|
for (final eventJson in decoded)
|
|
if (eventJson is Map<String, dynamic>)
|
|
NostrEvent.fromJson(eventJson)
|
|
else
|
|
throw const FormatException('relay returned malformed query event'),
|
|
];
|
|
} catch (error) {
|
|
if (error is FormatException) rethrow;
|
|
throw FormatException('relay returned malformed query event: $error');
|
|
}
|
|
}
|
|
|
|
/// The text of a relay response body, inflated when the server sent gzip.
|
|
///
|
|
/// The query transport asks for gzip and leaves it compressed so inflating
|
|
/// can happen off the UI isolate. A body that a transport already inflated
|
|
/// passes through. JSON never starts with the gzip magic bytes, so the bytes
|
|
/// decide, not the `Content-Encoding` header a transport may leave behind.
|
|
/// The charset rules are those of [http.Response.body].
|
|
///
|
|
/// With [maxInflatedBytes], a gzip body that expands past it throws a
|
|
/// [FormatException] as soon as it does, so a small compressed body cannot
|
|
/// expand without limit. Transport byte caps only see the compressed size.
|
|
String relayResponseText(
|
|
Uint8List bytes,
|
|
String? contentType, {
|
|
int? maxInflatedBytes,
|
|
}) {
|
|
final inflated = _isGzip(bytes) ? _inflate(bytes, maxInflatedBytes) : bytes;
|
|
return http.Response.bytes(
|
|
inflated,
|
|
200,
|
|
headers: {'content-type': ?contentType},
|
|
).body;
|
|
}
|
|
|
|
Uint8List _inflate(Uint8List bytes, int? maxBytes) {
|
|
if (maxBytes == null) return Uint8List.fromList(gzip.decode(bytes));
|
|
final output = BytesBuilder(copy: false);
|
|
final input = gzip.decoder.startChunkedConversion(
|
|
_CappedByteSink(output, maxBytes),
|
|
);
|
|
// Small input chunks bound how far one chunk can overshoot the cap.
|
|
const chunk = 4096;
|
|
for (var start = 0; start < bytes.length; start += chunk) {
|
|
final end = start + chunk < bytes.length ? start + chunk : bytes.length;
|
|
input.add(Uint8List.sublistView(bytes, start, end));
|
|
}
|
|
input.close();
|
|
return output.takeBytes();
|
|
}
|
|
|
|
class _CappedByteSink implements Sink<List<int>> {
|
|
_CappedByteSink(this._output, this._maxBytes);
|
|
|
|
final BytesBuilder _output;
|
|
final int _maxBytes;
|
|
|
|
@override
|
|
void add(List<int> data) {
|
|
if (_output.length + data.length > _maxBytes) {
|
|
throw FormatException(
|
|
'relay response exceeds $_maxBytes bytes after inflating',
|
|
);
|
|
}
|
|
_output.add(data);
|
|
}
|
|
|
|
@override
|
|
void close() {}
|
|
}
|
|
|
|
bool _isGzip(Uint8List bytes) =>
|
|
bytes.length > 2 && bytes[0] == 0x1f && bytes[1] == 0x8b;
|