mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
### Context Profiling on an iPhone 12 mini (Profile build, real community) showed `RelaySessionNotifier._postForEvents` producing contiguous main-isolate busy runs with p90 110 ms and max 124 ms (27 runs of 50 ms or longer). Stacks were dominated by `jsonDecode` of `response.body`, with `NostrEvent.fromJson` smaller. The whole HTTP response decode ran on the UI isolate: gzip inflate (done by `dart:io`), the UTF-8 decode behind `response.body`, JSON parsing, and event parsing. ### What changes - Successful `/query` and `/profiles/search` responses (the only two callers of `_postForEvents`) are now decoded in a single `Isolate.run` from `response.bodyBytes`: inflate, UTF-8, JSON, validation, `NostrEvent.fromJson`. The parsed events come back by ownership transfer (`Isolate.exit`), not a copy. - Gzip: the query transport now sends `Accept-Encoding: gzip` and sets `autoUncompress = false`, so compressed bodies reach the isolate still compressed. Whether bytes are gzip is decided by the gzip magic bytes, so a transport that already inflated (or an injected test client) still works. Error bodies are inflated inline (small). - Unchanged: `FormatException` messages, the malformed-event wrapping, `RelayException.body`, and the rate-limit gate reading the error body. - Fencing (Review-Proven Rule 2): the `_disposed` / `_ageRestricted` / `_contextGeneration` check still runs before the decode and now runs again after it. If the session retires during the decode, the query reports `StateError` even when the decode itself failed, matching the old ordering where a retired session never decoded. ### Design notes - `Isolate.run` per response instead of a long-lived worker: no lifecycle or disposal to manage, no idle isolate heap on a small phone, easy to fake in tests, and the same shape as `parseProfileEventBatch`. The main-isolate side of a spawn measured at microseconds, so there is no size threshold; small responses take the same path. - Event id and signature verification does not happen on these events on the main isolate today (nothing in the mobile app verifies queried events; NIP-OA verification in profile parsing is already in an isolate), so nothing was moved. - Other HTTP decode sites found and left alone: invite create/join, hosted join policy, BuilderLab account, media upload response, community icon lookup, dev push lease NIP-11, archived-agents. These decode small, rare responses (one small JSON object each), where an isolate adds complexity without a measurable gain. Media and avatar byte downloads do no JSON decoding. The mobile app has no `/count` caller. ### Validation Added tests (`relay_session_test.dart`, through the production `queryRelay` / `searchProfiles` path with the real isolate decoder): parsing with non-ASCII content; profile search; gzip success body and `Accept-Encoding`; gzip error body feeding the rate-limit gate and `RelayException.body`; malformed JSON; non-list body; non-object element; malformed event; invalid UTF-8; error status skips decoding; session disposed, age-restricted, or retired-before-decode while a decode is outstanding. The fencing tests are falsifiable: removing the post-decode check fails the disposed and age-restricted tests; removing the retired-wins-over-decode-error branch fails its test; removing the pre-decode check fails the never-decodes test. `FakeRelay` (test helper) now injects an inline decoder, because `fakeAsync` cannot deliver isolate results; it calls the same parse function. The default transport still honors `http.runWithClient`, which the hosted journey tests use to fake the network (`relay_http_query_client_test.dart`; fails without the fix). `just mobile-check` and `just mobile-test` pass. Host benchmark (AOT executable on an Apple silicon Mac, median of 25, copy of the parse logic, main-isolate stall measured with a 0.5 ms heartbeat timer): | Response | Main-isolate blocked before | after | | --- | --- | --- | | 500 events, ~630 KB | 2.4 ms | 0 ms (max gap 0.05 ms) | | 500 events, ~630 KB gzip | 3.2 ms | 0 ms (max gap 0.19 ms) | | 500 events with 50 20 KB avatars, ~1.6 MB | 5.1 ms | 0 ms (max gap 0.28 ms) | | 500 events, ~4.5 MB | 14.0 ms | 0 ms (max gap 0.27 ms) | | 500 events, ~4.5 MB (827 KB gzip) | 17.4 ms | 0 ms (max gap 0.35 ms) | Total latency is unchanged within noise (for example 14.5 ms to 14.9 ms). The host is much faster than the 12 mini, so absolute device savings should be several times larger. On the iPhone 12 mini (Profile builds of base and this branch, real community over the production relay, two runs per scenario; main-isolate stalls measured with a 4 ms heartbeat timer, since the wireless device tunnel has no VM service): | Scenario | Main-isolate `_postForEvents` time, base | this branch | Max stall, base | this branch | | --- | --- | --- | --- | --- | | Cold start + 60 s (~16k events, 7.8 MB gzip) | 282 to 295 ms (max 116 ms) | 0.01 ms | 147 to 150 ms | 100 to 102 ms | | Inbox + search (~7k events) | 122 to 123 ms (max 111 ms) | 0.01 ms | 138 to 142 ms | 97 to 99 ms | | Open 6 channels (558 events) | 5 to 6 ms | 0.01 ms | 88 ms | 86 to 87 ms | | Background 10 s + resume (~7k events) | 118 to 124 ms | 0.01 ms | n/a (suspension) | n/a | - The production relay serves gzip (about 4.4:1; the largest catch-up response is 3.15 MB on the wire, 13.9 MB inflated, 7k events). Inside the isolate that response takes 30 to 47 ms to inflate, 39 to 55 ms for UTF-8, 65 to 105 ms for `jsonDecode` and 7 to 32 ms for `NostrEvent.fromJson`. - Many 16 to 90 ms stalls remain on both builds; they come from other work (mostly markdown build and layout), not response decoding. - Home, channels, inbox, search, profile and a thread render the same on both builds. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>