Files
plezy/lib/utils/managed_http_client.dart
T
edde746 c4059d0ead fix(startup): stop Cronet and Plex Home from blocking time-to-interactive
Two cold-start findings from the same pass. They share a call site in
`MainScreen`'s post-frame block, so they land together.

## Cronet was 33% of time-to-interactive

`createPlatformClient()` built the shared `CronetEngine` inline, so whichever
consumer happened to create the first HTTP client paid for it — and that landed
between `database_ready` and `credentials_loaded`, i.e. squarely on the path to
the first usable screen.

Measured on the Amlogic SC2 box, phase marks relative to `dart_main`, by
temporarily forcing the existing `_cronetBroken` fallback so no engine is ever
built:

| phase | engine built inline | engine never built |
|---|---|---|
| database_ready | +455 | +456 |
| credentials_loaded | +1171 | +703 |
| binding_settled | +1331 | +827 |
| main_screen | +1394 | +932 |

So ~462 ms, fully serial. Logcat shows where it goes: `DynamiteModule
loadModule2NoCrashUtils` then `HttpFlagsLoader` reading
`com.google.android.gms/app_httpflags/flags.binarypb`. The cause is provider
*enumeration*, not selection — `CronetEngine.Builder(Context)` calls
`isEnabled()` on every registered provider, and `PlayServicesCronetProvider`
answers that by installing the Play services Dynamite module. `play-services-cronet`
arrives transitively through `media3-datasource-cronet`, and `package:cronet_http`
offers no way to choose a provider, so the only lever available in Dart is *when*
the cost is paid.

Android's `createPlatformClient()` now returns a client that resolves its
delegate per request: the tuned IOClient that already backstops a broken Cronet
until the shared engine exists, Cronet afterwards. Per-request matters — a
`MediaServerHttpClient` builds its client in a constructor initializer and lives
for the process, so deciding once at construction would have pinned primary
media-server traffic to HTTP/1.1 forever, which is a silent steady-state
regression rather than a fix. `warmUpPlatformHttpClient()` then builds the engine
from `MainScreen`'s post-frame block.

Result: `main_screen` +1394 -> +915 ms, and logcat carries both client lines
(`IOClient (Android fallback)` then `CronetClient`), proving the swap. The build
now runs from +1023 to +1419, entirely after the first screen, and produces no
Choreographer or Davey report — the UI is static waiting on hub content there, so
there are no frames to drop.

## Plex Home refresh raced the offline decision

`PlexHomeService.start()` conflated disk hydration with going live: it decoded
the cached `plex_home_users_*` entries *and* subscribed to connection changes,
installed a refresh timer and fired `_refreshAll()`. It was invoked straight from
a provider `create:`, so on a box with no network — or the flaky 2.4 GHz Wi-Fi
these devices typically have — it started requests that would time out during the
exact window the startup gate needs. Its immediate neighbour
`ActiveProfileBinder` is explicitly not auto-started for this reason and says so
in a comment; the same argument applied here and had simply not been followed.

`start()` is now the live/network entry point and `hydrate()` is the disk-only
half, coalesced and lifecycle-guarded like `start()` already was. The provider
`create:` hydrates; `_reloadSnapshot` and `reloadFromStorage` hydrate; the borrow
picker hydrates, because it reads `current` immediately and is reachable while
offline. Only `MainScreen` goes live, gated on `!_isOffline`, with
`_handleOfflineStatusChanged` picking it up if the session later regains network —
otherwise an airplane-mode launch would never refresh Plex Home again.

Hydration still `_emit()`s, so `stream`'s replay contract holds even when the
network side never starts, which is what keeps a late listener behind a
`combineLatest` off a permanent spinner.
2026-08-23 18:13:39 +02:00

328 lines
11 KiB
Dart

import 'dart:async';
import 'package:http/http.dart' as http;
import 'app_logger.dart';
/// [http.Client] that can abort and drain its active requests before closing.
///
/// Shutdown paths that must not outrun in-flight native callbacks (per-server
/// failover, server removal, app exit) await this instead of the
/// fire-and-forget [http.Client.close]. Composite clients that only delegate
/// to [ManagedHttpClient]s (`AndroidPlatformHttpClient`) implement it so the
/// awaited drain survives the extra layer.
abstract interface class GracefulHttpClient implements http.Client {
Future<void> closeGracefully({Duration drainTimeout});
}
/// [http.Client] wrapper that owns native-client shutdown semantics.
///
/// `package:http` clients define closing with active requests as undefined. For
/// platform clients backed by native callbacks, especially CupertinoClient,
/// closing at the wrong time can leave callbacks racing a torn-down Dart bridge.
/// This wrapper tracks requests until their response stream finishes, aborts
/// active requests during shutdown, and only closes the inner client once the
/// active set has drained — or, when [forceCloseOnDrainTimeout] opts in,
/// force-closes a drain-resistant inner client instead of leaking its sockets.
class ManagedHttpClient extends http.BaseClient implements GracefulHttpClient {
ManagedHttpClient(this._inner, {required this.debugLabel, this.forceCloseOnDrainTimeout = false}) {
_instances.add(this);
}
static final Set<ManagedHttpClient> _instances = <ManagedHttpClient>{};
static Future<void> closeAllGracefully({Duration drainTimeout = const Duration(seconds: 5)}) async {
await Future.wait(
_instances.toList().map((client) => client.closeGracefully(drainTimeout: drainTimeout)),
eagerError: false,
);
}
final http.Client _inner;
final String debugLabel;
/// Whether [_inner] tolerates [http.Client.close] with requests still in
/// flight. dart:io clients do — `HttpClient.close(force: true)` promptly
/// fails pending requests, including a TCP connect that `package:http`
/// cannot abort because the abort handler is only registered once `openUrl`
/// completes. Native-callback clients (CupertinoClient) do not; they keep
/// the deferred-close behavior.
final bool forceCloseOnDrainTimeout;
final Set<_TrackedRequest> _active = <_TrackedRequest>{};
bool _closing = false;
bool _innerClosed = false;
Future<void>? _closeFuture;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
if (_closing) {
throw http.ClientException('HTTP client is closing', request.url);
}
final tracked = _TrackedRequest(request.url);
_active.add(tracked);
try {
final abortableRequest = _wrapRequest(request, tracked.abortTrigger);
final response = await _inner.send(abortableRequest);
return _wrapResponse(response, tracked);
} catch (_) {
_complete(tracked);
rethrow;
}
}
@override
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) {
_closing = true;
if (_innerClosed) return Future<void>.value();
final existing = _closeFuture;
if (existing != null) return existing;
final future = _closeGracefully(drainTimeout);
_closeFuture = future;
unawaited(
future.then<void>(
(_) {
if (!_innerClosed && identical(_closeFuture, future)) {
_closeFuture = null;
}
},
onError: (Object _, StackTrace _) {
if (!_innerClosed && identical(_closeFuture, future)) {
_closeFuture = null;
}
},
),
);
return future;
}
@override
void close() {
unawaited(closeGracefully());
}
Future<void> _closeGracefully(Duration drainTimeout) async {
await _abortActive();
if (_active.isNotEmpty) {
try {
await Future.wait(_active.map((request) => request.done), eagerError: false).timeout(drainTimeout);
} on TimeoutException {
if (forceCloseOnDrainTimeout) {
// A request stuck in TCP connect holds the drain open until the OS
// connect timeout (~75 s of SYN retries on Darwin). The inner client
// fails in-flight requests promptly on close, so reclaim the sockets
// instead of deferring.
appLogger.d(
'HTTP client drain timed out, force-closing',
error: {'client': debugLabel, 'activeRequests': _active.length},
);
_closeInner();
return;
}
appLogger.w('HTTP client drain timed out', error: {'client': debugLabel, 'activeRequests': _active.length});
}
}
_tryCloseInner();
if (!_innerClosed) {
appLogger.w(
'HTTP client close deferred until active requests finish',
error: {'client': debugLabel, 'activeRequests': _active.length},
);
}
}
Future<void> _abortActive() async {
await Future.wait(_active.toList().map((request) => request.cancel()), eagerError: false);
}
http.BaseRequest _wrapRequest(http.BaseRequest request, Future<void> managedAbortTrigger) {
final requestAbortTrigger = request is http.Abortable ? request.abortTrigger : null;
final abortTrigger = requestAbortTrigger == null
? managedAbortTrigger
: Future.any<void>([managedAbortTrigger, requestAbortTrigger]);
final body = request.finalize();
final abortable = http.AbortableStreamedRequest(request.method, request.url, abortTrigger: abortTrigger)
..headers.addAll(request.headers)
..followRedirects = request.followRedirects
..maxRedirects = request.maxRedirects
..persistentConnection = request.persistentConnection
..contentLength = request.contentLength;
unawaited(
body.pipe(abortable.sink).catchError((Object e, StackTrace st) {
appLogger.d('HTTP request body pipe failed', error: e, stackTrace: st);
}),
);
return abortable;
}
http.StreamedResponse _wrapResponse(http.StreamedResponse response, _TrackedRequest tracked) {
late final StreamController<List<int>> controller;
StreamSubscription<List<int>>? subscription;
var subscribed = false;
var cancelledBeforeListen = false;
// Cancellation is not an empty successful response: deliver the abort as
// an error so downstream mapping (MediaServerHttpException.from) reports
// `cancelled` instead of handing consumers a clean empty body. Runs at
// most once — whichever of cancelResponse/onListen gets there first.
void abortOutput() {
if (controller.isClosed) return;
controller.addError(http.RequestAbortedException(tracked.url));
unawaited(controller.close());
}
Future<void> cancelResponse() async {
if (tracked.isDone) return;
tracked.abort();
cancelledBeforeListen = !subscribed;
if (subscribed) {
await subscription?.cancel();
} else {
// Subscribe-and-cancel releases the inner transport stream nobody is
// reading. Post-abort transport errors are expected here, but not
// silently: the outer consumer gets the abort from abortOutput.
final cancelSubscription = response.stream.listen(
null,
onError: (Object e, StackTrace st) {
appLogger.d('HTTP response release stream error during cancellation', error: e, stackTrace: st);
},
);
await cancelSubscription.cancel();
}
abortOutput();
_complete(tracked);
}
controller = StreamController<List<int>>(
sync: true,
onListen: () {
if (cancelledBeforeListen) {
abortOutput();
return;
}
subscribed = true;
subscription = response.stream.listen(
controller.add,
onError: controller.addError,
onDone: () {
_complete(tracked);
unawaited(controller.close());
},
);
},
onPause: () => subscription?.pause(),
onResume: () => subscription?.resume(),
onCancel: () async {
tracked.abort();
await subscription?.cancel();
_complete(tracked);
},
);
tracked.cancelResponse = cancelResponse;
if (response case http.BaseResponseWithUrl(:final url)) {
return _ManagedStreamedResponseWithUrl(
controller.stream,
response.statusCode,
url: url,
contentLength: response.contentLength,
request: response.request,
headers: response.headers,
isRedirect: response.isRedirect,
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
);
}
return http.StreamedResponse(
controller.stream,
response.statusCode,
contentLength: response.contentLength,
request: response.request,
headers: response.headers,
isRedirect: response.isRedirect,
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
);
}
void _complete(_TrackedRequest tracked) {
if (!_active.remove(tracked)) return;
tracked.complete();
if (_closing && _active.isEmpty) {
_tryCloseInner();
}
}
void _tryCloseInner() {
if (_active.isNotEmpty) return;
_closeInner();
}
void _closeInner() {
if (_innerClosed) return;
try {
_inner.close();
_innerClosed = true;
_instances.remove(this);
} catch (e, st) {
appLogger.w('HTTP client close failed', error: e, stackTrace: st);
}
}
}
class _ManagedStreamedResponseWithUrl extends http.StreamedResponse implements http.BaseResponseWithUrl {
_ManagedStreamedResponseWithUrl(
super.stream,
super.statusCode, {
required this.url,
super.contentLength,
super.request,
super.headers,
super.isRedirect,
super.persistentConnection,
super.reasonPhrase,
});
@override
final Uri url;
}
/// Deliberately not `AbortController`: this layer stays a plain [http.Client]
/// with no media-server dependency, and it needs two independent latches
/// (aborted vs. drained) plus the response canceller.
class _TrackedRequest {
_TrackedRequest(this.url);
final Uri url;
final Completer<void> _abortCompleter = Completer<void>();
final Completer<void> _doneCompleter = Completer<void>();
Future<void> get abortTrigger => _abortCompleter.future;
Future<void> get done => _doneCompleter.future;
bool get isDone => _doneCompleter.isCompleted;
Future<void> Function()? cancelResponse;
void abort() {
if (!_abortCompleter.isCompleted) _abortCompleter.complete();
}
Future<void> cancel() async {
abort();
await cancelResponse?.call();
}
void complete() {
if (!_doneCompleter.isCompleted) _doneCompleter.complete();
}
}