Files
buzz/mobile/test/helpers
Tom Brow 770ab7ff82 perf(mobile): decode relay query responses off the UI isolate (#8669)
### 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>
2026-10-29 08:44:52 -07:00
..