mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
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>
286 lines
10 KiB
Dart
286 lines
10 KiB
Dart
import 'dart:async';
|
|
import 'dart:io' show Platform;
|
|
|
|
import 'package:cronet_http/cronet_http.dart';
|
|
import 'package:cupertino_http/cupertino_http.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:jni/jni.dart' show JThrowable;
|
|
import 'package:objective_c/objective_c.dart';
|
|
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
|
|
|
/// Which app-wide connection pool a request belongs to.
|
|
enum HttpTraffic {
|
|
/// Relay queries, NIP-11 documents, account and admission calls.
|
|
api,
|
|
|
|
/// Image, video and voice-note downloads and media uploads.
|
|
media,
|
|
}
|
|
|
|
/// The app-wide session for each traffic class, or null where the platform
|
|
/// has none.
|
|
final _sessions = <HttpTraffic, http.Client?>{};
|
|
|
|
/// An HTTP client for [traffic]. Callers own it and close it as usual.
|
|
///
|
|
/// On iOS and Android, every client for the same [traffic] shares one native
|
|
/// session (`NSURLSession` or a Cronet engine), so requests to a host
|
|
/// multiplex over one HTTP/2 connection instead of opening an HTTP/1.1
|
|
/// connection each, and gzip is inflated natively, off the Dart isolate.
|
|
/// Media has its own session, and so its own connection, so image bytes
|
|
/// cannot starve queries queued behind them on a shared connection.
|
|
///
|
|
/// Elsewhere this is `http.Client()`, which also honors `http.runWithClient`.
|
|
http.Client newAppHttpClient([HttpTraffic traffic = HttpTraffic.api]) {
|
|
final session = _sessions.putIfAbsent(traffic, _openNativeSession);
|
|
if (session == null) return http.Client();
|
|
return SharedSessionClient(session, streamsUploads: !Platform.isAndroid);
|
|
}
|
|
|
|
/// Whether [newAppHttpClient] uses a native session on this device.
|
|
bool get hasNativeHttpSession =>
|
|
_sessions.putIfAbsent(HttpTraffic.api, _openNativeSession) != null;
|
|
|
|
http.Client? _openNativeSession() {
|
|
if (Platform.isIOS) return _openUrlSession();
|
|
if (Platform.isAndroid) return _openCronetSession();
|
|
return null;
|
|
}
|
|
|
|
/// Matches the `dart:io` transport apart from the protocol: no HTTP cache and
|
|
/// no cookies.
|
|
http.Client _openUrlSession() {
|
|
final config = URLSessionConfiguration.ephemeralSessionConfiguration()
|
|
..cache = null
|
|
..httpShouldSetCookies = false
|
|
..httpCookieAcceptPolicy =
|
|
NSHTTPCookieAcceptPolicy.NSHTTPCookieAcceptPolicyNever;
|
|
return CupertinoClient.fromSessionConfiguration(config);
|
|
}
|
|
|
|
/// Matches the `dart:io` transport apart from the protocol: no HTTP cache,
|
|
/// and Cronet keeps no cookies. QUIC stays off, so this changes only the
|
|
/// HTTP version.
|
|
///
|
|
/// Null when no Cronet provider is available: Cronet comes from Google Play
|
|
/// services, which some Android devices don't have. Those keep `dart:io`.
|
|
http.Client? _openCronetSession() {
|
|
final CronetEngine engine;
|
|
try {
|
|
engine = CronetEngine.build(
|
|
cacheMode: CacheMode.disabled,
|
|
enableHttp2: true,
|
|
enableQuic: false,
|
|
);
|
|
} on JThrowable catch (error) {
|
|
debugPrint('Cronet is unavailable, using dart:io: ${error.message}');
|
|
return null;
|
|
}
|
|
return CronetClient.fromCronetEngine(engine);
|
|
}
|
|
|
|
/// A caller's handle on an app-wide session. Like closing a `dart:io` client,
|
|
/// [close] cancels the handle's requests still in flight and refuses later
|
|
/// ones, but leaves the session open for other handles. Closing a
|
|
/// `CupertinoClient` while any of its requests run would also throw, and
|
|
/// closing a `CronetClient` would stop the shared engine.
|
|
///
|
|
/// App Transport Security blocks cleartext `URLSession` loads from public
|
|
/// hosts, while saved community icons and remote media may still use
|
|
/// `http://`. Those keep the handle's own `dart:io` client, as before.
|
|
///
|
|
/// Unless [streamsUploads], a streamed request with a body (media and voice
|
|
/// note uploads) also keeps `dart:io`: `CronetClient` reads the whole body
|
|
/// into memory before sending it, so progress would reach 100% before any
|
|
/// byte left and a large attachment would be copied in full.
|
|
@visibleForTesting
|
|
class SharedSessionClient extends http.BaseClient {
|
|
SharedSessionClient(
|
|
this._session, {
|
|
http.Client Function()? cleartext,
|
|
this.streamsUploads = true,
|
|
}) : _newCleartext = cleartext ?? http.Client.new;
|
|
|
|
final http.Client _session;
|
|
final http.Client Function() _newCleartext;
|
|
|
|
/// Whether the session streams request bodies instead of buffering them.
|
|
final bool streamsUploads;
|
|
|
|
/// One abort trigger per request still running, so [close] can cancel it.
|
|
/// A request leaves the set once its response body is done, failed or
|
|
/// cancelled, or once the caller's own abort trigger fires. A body nobody
|
|
/// listens to stays until then: the session keeps downloading it, so
|
|
/// callers that drop a response unread must abort it.
|
|
final _inFlight = <Completer<void>>{};
|
|
var _closed = false;
|
|
http.Client? _cleartext;
|
|
|
|
@override
|
|
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
|
if (_closed) {
|
|
throw http.ClientException(
|
|
'HTTP request failed. Client is already closed.',
|
|
request.url,
|
|
);
|
|
}
|
|
if (request.url.scheme != 'https' ||
|
|
(!streamsUploads && _isStreamedUpload(request))) {
|
|
return (_cleartext ??= _newCleartext()).send(request);
|
|
}
|
|
final abort = Completer<void>();
|
|
void fire() {
|
|
_inFlight.remove(abort);
|
|
if (!abort.isCompleted) abort.complete();
|
|
}
|
|
|
|
_inFlight.add(abort);
|
|
if (request case http.Abortable(:final abortTrigger?)) {
|
|
abortTrigger.then<void>((_) => fire(), onError: (_) => fire());
|
|
}
|
|
final http.StreamedResponse response;
|
|
try {
|
|
response = await _session.send(_withAbortTrigger(request, abort.future));
|
|
} catch (_) {
|
|
_inFlight.remove(abort);
|
|
rethrow;
|
|
}
|
|
// A controller, not an async* wrapper: async* never listens to the
|
|
// session's stream when cancelled before its first event, so the
|
|
// session would keep downloading a body nobody reads.
|
|
late final StreamSubscription<List<int>> inner;
|
|
final body = StreamController<List<int>>(sync: true);
|
|
body
|
|
..onListen = () {
|
|
inner = response.stream.listen(
|
|
body.add,
|
|
onError: body.addError,
|
|
onDone: () {
|
|
_inFlight.remove(abort);
|
|
body.close();
|
|
},
|
|
);
|
|
}
|
|
..onPause = (() => inner.pause())
|
|
..onResume = (() => inner.resume())
|
|
..onCancel = () {
|
|
_inFlight.remove(abort);
|
|
return inner.cancel();
|
|
};
|
|
|
|
return http.StreamedResponse(
|
|
http.ByteStream(body.stream),
|
|
response.statusCode,
|
|
contentLength: response.contentLength,
|
|
request: response.request,
|
|
headers: response.headers,
|
|
isRedirect: response.isRedirect,
|
|
persistentConnection: response.persistentConnection,
|
|
reasonPhrase: response.reasonPhrase,
|
|
);
|
|
}
|
|
|
|
static bool _isStreamedUpload(http.BaseRequest request) =>
|
|
request is! http.Request &&
|
|
request.method != 'GET' &&
|
|
request.method != 'HEAD';
|
|
|
|
/// A copy of [request] that the session cancels when [trigger] completes.
|
|
http.BaseRequest _withAbortTrigger(
|
|
http.BaseRequest request,
|
|
Future<void> trigger,
|
|
) {
|
|
if (request is http.Request) {
|
|
return http.AbortableRequest(
|
|
request.method,
|
|
request.url,
|
|
abortTrigger: trigger,
|
|
)
|
|
..bodyBytes = request.bodyBytes
|
|
..headers.addAll(request.headers)
|
|
..followRedirects = request.followRedirects
|
|
..maxRedirects = request.maxRedirects
|
|
..persistentConnection = request.persistentConnection;
|
|
}
|
|
// Finalizing first lets a multipart request set its content type.
|
|
final body = request.finalize();
|
|
final copy =
|
|
http.AbortableStreamedRequest(
|
|
request.method,
|
|
request.url,
|
|
abortTrigger: trigger,
|
|
)
|
|
..headers.addAll(request.headers)
|
|
..contentLength = request.contentLength
|
|
..followRedirects = request.followRedirects
|
|
..maxRedirects = request.maxRedirects
|
|
..persistentConnection = request.persistentConnection;
|
|
// addStream keeps backpressure: the body is read only as the session
|
|
// reads the copy, so upload progress tracks bytes the session has taken
|
|
// (including its read-ahead buffers), as with plain cupertino_http.
|
|
unawaited(copy.sink.addStream(body).whenComplete(copy.sink.close));
|
|
return copy;
|
|
}
|
|
|
|
@override
|
|
void close() {
|
|
_closed = true;
|
|
for (final abort in _inFlight.toList()) {
|
|
if (!abort.isCompleted) abort.complete();
|
|
}
|
|
_inFlight.clear();
|
|
_cleartext?.close();
|
|
}
|
|
}
|
|
|
|
/// How a native transport request failed, for user-facing messages.
|
|
/// `dart:io` failures are recognized by their messages instead.
|
|
enum TransportFailure { unreachable, insecure, timedOut }
|
|
|
|
/// The [TransportFailure] for a `URLSession` or Cronet error, or null for
|
|
/// any other error.
|
|
TransportFailure? transportFailure(Object error) => switch (error) {
|
|
NSErrorClientException(:final error)
|
|
when error.domain.toDartString() == 'NSURLErrorDomain' =>
|
|
urlErrorFailure(error.code),
|
|
NetworkException(:final errorCode, :final cronetInternalErrorCode) =>
|
|
cronetErrorFailure(errorCode, cronetInternalErrorCode),
|
|
_ => null,
|
|
};
|
|
|
|
/// Classifies an `NSURLErrorDomain` code.
|
|
@visibleForTesting
|
|
TransportFailure? urlErrorFailure(int code) => switch (code) {
|
|
-1001 => TransportFailure.timedOut,
|
|
// Cannot find host, cannot connect, connection lost, DNS lookup failed,
|
|
// international roaming off, cellular data not allowed, offline.
|
|
-1003 ||
|
|
-1004 ||
|
|
-1005 ||
|
|
-1006 ||
|
|
-1018 ||
|
|
-1020 ||
|
|
-1009 => TransportFailure.unreachable,
|
|
// Secure connection failed and the server certificate errors.
|
|
>= -1206 && <= -1200 => TransportFailure.insecure,
|
|
_ => null,
|
|
};
|
|
|
|
/// Classifies a Cronet `NetworkException` by its `ERROR_*` code and, for
|
|
/// `ERROR_OTHER`, Chromium's internal net error code.
|
|
@visibleForTesting
|
|
TransportFailure? cronetErrorFailure(int errorCode, int internalCode) =>
|
|
switch (errorCode) {
|
|
4 || 6 => TransportFailure.timedOut,
|
|
// Host not resolved, offline, network changed, connection closed,
|
|
// refused or reset, address unreachable.
|
|
1 || 2 || 3 || 5 || 7 || 8 || 9 => TransportFailure.unreachable,
|
|
// SSL protocol error, cipher mismatch, and the certificate errors.
|
|
11
|
|
when internalCode == -107 ||
|
|
internalCode == -113 ||
|
|
(internalCode <= -200 && internalCode >= -299) =>
|
|
TransportFailure.insecure,
|
|
_ => null,
|
|
};
|