Files
buzz/mobile/lib/shared/relay/relay_http_query_client.dart
Tom Brow d1a8c3607d perf(mobile): use HTTP/2 on Android through app-wide Cronet engines (#9435)
Follows #8897 (iOS HTTP/2), now on main.

## What

Android gets the same app-wide shared sessions as iOS, backed by Cronet
(`cronet_http`) instead of `NSURLSession`:

- One Cronet engine per traffic class (API, media), so requests to a
host multiplex over one HTTP/2 connection and gzip is inflated natively,
off the Dart isolate.
- HTTP/2 on, HTTP cache off, QUIC off. QUIC would be a separate behavior
change, and `cronet_http`'s changelog lists DNS failures with QUIC on
cellular.
- Streamed uploads (media and voice notes) stay on `dart:io` on Android:
`CronetClient` reads a request body into memory before sending it, which
would break upload progress and copy large attachments in full.
- If no Cronet provider is available (no Google Play services), the
engine fails to build and the app keeps `dart:io`, as today.
- Transport error classification covers Cronet failures alongside
`NSURLSession` ones, so invite and pairing errors keep their friendly
messages.

## Measurements

Android emulator (API 36, arm64), profile builds of this branch and
main, signed in to the Feedback community. 8 cold starts per build,
alternating builds in blocks of 4. Times are for relay `/query` requests
started in the first 8 s.

| | main (`dart:io`) | this branch (Cronet) |
|---|---|---|
| First query response | 6.99 s | 1.99 s |
| Startup burst settled | 8.36 s | 2.78 s |
| Burst after the first connection | 1.44 s | 0.77 s |
| Query latency after the first connection, median / p90 | 165 / 1567 ms
| 106 / 158 ms |

**Unexplained stall:** most of main's startup gap is a ~5 s stall before
its first response on every cold start. Its cause is unconfirmed; my
guess is `dart:io` loading its CA store on the emulator, which HTTP/2
itself wouldn't fix, so the first two rows likely overstate the win on
real devices. The rows after the first connection exclude the stall and
are the fairer comparison. I also tried emulated EDGE throttling, but
the emulator's throttle did not slow its traffic, so there are no
slow-link numbers yet.

## Regressions

None found. Devices without Play services keep `dart:io`. Cleartext
`http://` requests keep the handle's own `dart:io` client, as on iOS.

## Testing

- Full mobile suite passes (`flutter test`, 4784 tests).
- Automated app run on the emulator: onboarding, Feedback nsec import,
channel and DM list load, and 16 cold starts with request timing. Media
loading wasn't separately exercised.
- No visible UI change, so no screenshots.

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-13 13:33:58 -08:00

208 lines
6.4 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:buzz/shared/http/app_http_client.dart';
/// The default transport. On iOS and Android, the app-wide native session
/// inflates gzip natively, off the Dart isolate. `dart:io` would inflate on
/// the calling isolate, so leave its responses compressed for the response
/// decoder.
///
/// A non-`dart:io` client supplied through `http.runWithClient`, such as a
/// `MockClient`, is used as is. An `IOClient` is replaced, not closed, since
/// it may be shared.
http.Client _defaultTransport() {
if (hasNativeHttpSession) return newAppHttpClient();
final client = http.Client();
if (client is! IOClient) return client;
return IOClient(HttpClient()..autoUncompress = false);
}
/// Owns the reusable HTTP transport used for relay `/query` requests.
///
/// Requests advertise gzip. A response body may therefore come back
/// compressed; decode it with `relayResponseText` or `decodeRelayQueryResponse`.
class RelayHttpQueryClient {
RelayHttpQueryClient({
http.Client? client,
http.Client Function()? clientFactory,
}) : _injectedClient = client,
_clientFactory = clientFactory ?? _defaultTransport;
final http.Client? _injectedClient;
final http.Client Function() _clientFactory;
_ClientGeneration? _currentGeneration;
final Set<_ClientGeneration> _generations = {};
Future<http.Response> post(
Uri url, {
required Map<String, String> headers,
required List<int> body,
required Duration timeout,
http.Client Function(http.Client, bool Function())? decorate,
}) async {
final generation = _injectedClient == null
? (_currentGeneration ??= _createGeneration())
: null;
generation?.acquire();
var active = true;
// Retiring a generation cannot stop a request on a shared session, so a
// timed-out request is aborted rather than left holding bandwidth.
final abort = Completer<void>();
try {
final request =
http.AbortableRequest('POST', url, abortTrigger: abort.future)
..followRedirects = false
..headers['Accept-Encoding'] = 'gzip'
..headers.addAll(headers)
..bodyBytes = body;
final transport = _injectedClient ?? generation!.client;
final client = decorate?.call(transport, () => active) ?? transport;
return await client
.send(request)
.then(http.Response.fromStream)
.timeout(timeout);
} on TimeoutException {
if (!abort.isCompleted) abort.complete();
if (identical(_currentGeneration, generation)) {
_currentGeneration = null;
}
generation?.retire();
rethrow;
} finally {
active = false;
generation?.release();
}
}
/// Sends a non-redirecting request, bounding bytes before buffering or parsing.
/// The deadline includes headers and the entire response body.
Future<http.Response> sendBounded(
http.Request request, {
required int maxBytes,
required Duration timeout,
http.Client Function(http.Client, bool Function())? decorate,
}) async {
// As in [post], a timed-out request on a shared session is aborted.
final abort = Completer<void>();
final sent =
http.AbortableRequest(
request.method,
request.url,
abortTrigger: abort.future,
)
..followRedirects = false
..headers.addAll(request.headers)
..bodyBytes = request.bodyBytes;
final generation = _injectedClient == null
? (_currentGeneration ??= _createGeneration())
: null;
generation?.acquire();
StreamIterator<List<int>>? iterator;
var finished = false;
Future<http.Response> receive() async {
final transport = _injectedClient ?? generation!.client;
final client = decorate?.call(transport, () => !finished) ?? transport;
final response = await client.send(sent);
if (finished) {
await response.stream.listen(null).cancel();
throw StateError('Relay request already timed out');
}
iterator = StreamIterator(response.stream);
if ((response.contentLength ?? 0) > maxBytes) {
throw const FormatException('Relay response exceeds byte limit');
}
final bytes = <int>[];
while (await iterator!.moveNext()) {
final chunk = iterator!.current;
if (bytes.length + chunk.length > maxBytes) {
throw const FormatException('Relay response exceeds byte limit');
}
bytes.addAll(chunk);
}
iterator = null; // An exhausted stream needs no cancellation.
return http.Response.bytes(
bytes,
response.statusCode,
headers: response.headers,
request: request,
);
}
try {
return await receive().timeout(timeout);
} on TimeoutException {
if (!abort.isCompleted) abort.complete();
if (identical(_currentGeneration, generation)) {
_currentGeneration = null;
}
generation?.retire();
rethrow;
} finally {
finished = true;
// Cancelling an iterator that never listened leaves the body
// downloading on a shared session, so abort on every exit.
if (!abort.isCompleted) abort.complete();
if (iterator != null) await iterator!.cancel();
generation?.release();
}
}
void close() {
for (final generation in _generations.toList()) {
generation.close();
}
_currentGeneration = null;
_injectedClient?.close();
}
_ClientGeneration _createGeneration() {
late final _ClientGeneration generation;
generation = _ClientGeneration(
_clientFactory(),
onClosed: () => _generations.remove(generation),
);
_generations.add(generation);
return generation;
}
}
class _ClientGeneration {
_ClientGeneration(this.client, {required this.onClosed});
final http.Client client;
final void Function() onClosed;
int _activeRequests = 0;
bool _retired = false;
bool _closed = false;
void acquire() {
assert(!_closed);
_activeRequests++;
}
void release() {
assert(_activeRequests > 0);
_activeRequests--;
_closeIfIdle();
}
void retire() {
_retired = true;
_closeIfIdle();
}
void close() {
if (_closed) return;
_closed = true;
client.close();
onClosed();
}
void _closeIfIdle() {
if (_retired && _activeRequests == 0) close();
}
}