Files
plezy/lib/utils/platform_http_client_io.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

171 lines
6.5 KiB
Dart

import 'dart:io' show HttpClient, Platform;
import 'package:cronet_http/cronet_http.dart';
import 'package:cupertino_http/cupertino_http.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:win_http/win_http.dart';
import 'app_logger.dart';
import 'managed_http_client.dart';
import 'media_server_timeouts.dart';
/// Shared Cronet engine so all clients reuse the same connection pool.
CronetEngine? _sharedEngine;
bool _cronetBroken = false;
Future<void>? _cronetWarmUp;
const String _androidCronetLabel = 'CronetClient';
const String _androidIoLabel = 'IOClient (Android fallback)';
const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
final Set<String> _loggedPlatformClients = <String>{};
void _logPlatformClient(String platform, String client) {
if (!_loggedPlatformClients.add(client)) return;
appLogger.i('Platform HTTP client', error: {'platform': platform, 'client': client});
}
/// Builds the shared Cronet engine, off the cold-start critical path.
///
/// [CronetEngine.build] goes straight to `org.chromium.net.CronetEngine.Builder`,
/// which enumerates every registered `CronetProvider` and calls `isEnabled()` on
/// each one. `play-services-cronet` — pulled in transitively by
/// `media3-datasource-cronet` — answers that call by installing the Play services
/// Dynamite module and resolving GMS HTTP flags. `package:cronet_http` exposes no
/// way to pick a provider, so the only lever left in Dart is *when* that cost is
/// paid. Call this once the first screen is up.
///
/// Never throws: a failed build latches Android onto the tuned IOClient.
Future<void> warmUpPlatformHttpClient() => _cronetWarmUp ??= _buildSharedCronetEngine();
Future<void> _buildSharedCronetEngine() async {
if (!Platform.isAndroid || _cronetBroken || _sharedEngine != null) return;
// Yield first: callers warm up from a post-frame callback, and the build is
// synchronous JNI work that must not land inside that frame.
await Future<void>.delayed(Duration.zero);
try {
_sharedEngine = CronetEngine.build(
cacheMode: CacheMode.memory,
cacheMaxSize: 2 * 1024 * 1024,
enableBrotli: true,
enableHttp2: true,
);
} catch (e, st) {
_cronetBroken = true;
_sharedEngine = null;
appLogger.w('CronetEngine build failed, staying on IOClient', error: e, stackTrace: st);
}
}
/// Android client that starts on the tuned IOClient and swaps to Cronet as soon
/// as [warmUpPlatformHttpClient] has built the shared engine.
///
/// Clients here are long-lived (one `MediaServerHttpClient` per server, created
/// in a constructor initializer), so resolving a delegate once at construction
/// would pin the primary media-server traffic to HTTP/1.1 forever. The delegate
/// is therefore resolved per request. Each delegate is a [ManagedHttpClient] in
/// its own right, keeping the existing shutdown semantics, and neither is
/// constructed until a request actually needs it.
class AndroidPlatformHttpClient extends http.BaseClient implements GracefulHttpClient {
ManagedHttpClient? _cronet;
ManagedHttpClient? _io;
bool _closed = false;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
if (_closed) {
throw http.ClientException('HTTP client is closing', request.url);
}
return _delegate().send(request);
}
ManagedHttpClient _delegate() {
final engine = _sharedEngine;
if (engine != null) {
final cronet = _cronet;
if (cronet != null) return cronet;
_logPlatformClient('android', _androidCronetLabel);
return _cronet = ManagedHttpClient(CronetClient.fromCronetEngine(engine), debugLabel: _androidCronetLabel);
}
final io = _io;
if (io != null) return io;
_logPlatformClient('android', _androidIoLabel);
return _io = _createIoClient(_androidIoLabel, tuned: true);
}
@override
void close() {
_closed = true;
_cronet?.close();
_io?.close();
}
@override
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) async {
_closed = true;
await Future.wait([
if (_cronet case final cronet?) cronet.closeGracefully(drainTimeout: drainTimeout),
if (_io case final io?) io.closeGracefully(drainTimeout: drainTimeout),
], eagerError: false);
}
}
/// dart:io leaves TCP connects unbounded (Darwin retries SYNs for ~75 s) and
/// `package:http` cannot abort a request whose connection is still being
/// established, so every IOClient gets an explicit connect bound and
/// permission to force-close after a failed drain (#1972).
ManagedHttpClient _createIoClient(String debugLabel, {bool tuned = false}) {
final httpClient = HttpClient()..connectionTimeout = MediaServerTimeouts.connect;
if (tuned) {
// Plex home loads fan out many HTTP/1.1 calls; keep connections warm.
httpClient
..maxConnectionsPerHost = 12
..idleTimeout = const Duration(seconds: 90);
}
return ManagedHttpClient(IOClient(httpClient), debugLabel: debugLabel, forceCloseOnDrainTimeout: true);
}
http.Client createPlatformClient() {
if (Platform.isAndroid) {
return AndroidPlatformHttpClient();
}
if (Platform.isIOS && _tvosBuild) {
_logPlatformClient('tvos', 'IOClient (tvOS tuned)');
return _createIoClient('IOClient (tvOS tuned)', tuned: true);
}
if (Platform.isIOS || Platform.isMacOS) {
try {
final client = CupertinoClient.defaultSessionConfiguration();
_logPlatformClient(Platform.isIOS ? 'ios' : 'macos', 'CupertinoClient');
return ManagedHttpClient(client, debugLabel: 'CupertinoClient');
} catch (e, st) {
appLogger.w('CupertinoClient init failed, falling back to IOClient', error: e, stackTrace: st);
_logPlatformClient(Platform.isIOS ? 'ios' : 'macos', 'IOClient (fallback)');
return _createIoClient('IOClient (fallback)');
}
}
if (Platform.isWindows) {
try {
final client = WinHttpClient.defaultConfiguration();
_logPlatformClient('windows', 'WinHttpClient');
return ManagedHttpClient(client, debugLabel: 'WinHttpClient');
} catch (e, st) {
appLogger.w('WinHttpClient init failed, falling back to IOClient', error: e, stackTrace: st);
_logPlatformClient('windows', 'IOClient (fallback)');
return _createIoClient('IOClient (fallback)');
}
}
_logPlatformClient(Platform.operatingSystem, 'IOClient');
return _createIoClient('IOClient');
}
http.Client createPlexApiClient() {
if (Platform.isLinux) {
_logPlatformClient('linux', 'IOClient (Plex API tuned)');
return _createIoClient('IOClient (Plex API tuned)', tuned: true);
}
return createPlatformClient();
}