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.
This commit is contained in:
edde746
2026-08-23 18:13:39 +02:00
parent f95465067e
commit c4059d0ead
12 changed files with 310 additions and 46 deletions
+4 -4
View File
@@ -1561,15 +1561,15 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
Provider<ProfileConnectionRegistry>(create: (_) => ProfileConnectionRegistry(_appDatabase)),
Provider<PlexHomeService>(
create: (context) {
// start() resolves StorageService internally — the singleton was
// already initialised eagerly during boot, so the await is a
// microtask hop in practice.
// Hydrate the disk cache eagerly for profile resolution. Live
// refresh is started only after MainScreen has settled the
// startup offline decision.
final service = PlexHomeService(
connections: context.read<ConnectionRegistry>(),
profileConnections: context.read<ProfileConnectionRegistry>(),
storage: context.read<StorageService>(),
);
unawaited(service.start());
unawaited(service.hydrate());
return service;
},
dispose: (_, s) => s.dispose(),
+2 -2
View File
@@ -231,8 +231,8 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
Future<void> _reloadSnapshot() async {
_storage ??= await StorageService.getInstance();
// Hydrate the Plex Home cache before we read it — `_plexHome.current`
// is only populated after start() finishes its disk-cache load.
await _plexHome.start();
// is only populated after the disk-cache load finishes.
await _plexHome.hydrate();
_localProfiles = await _registry.list();
final initialConns = await _connections.list();
+35 -6
View File
@@ -38,6 +38,7 @@ class PlexHomeService {
final _controller = StreamController<Map<String, List<PlexHomeUser>>>.broadcast();
StreamSubscription<List<Connection>>? _connSub;
Timer? _refreshTimer;
Future<void>? _hydrateFuture;
Future<void>? _startFuture;
bool _started = false;
final Map<String, int> _refreshGenerations = {};
@@ -56,7 +57,7 @@ class PlexHomeService {
/// Emits the current snapshot immediately on subscribe, then forwards
/// every change from [_controller]. Without the seed emission, late
/// subscribers (e.g. the profiles management screen, which mounts long
/// after [start] fires its initial `_emit`) sit on `ConnectionState.waiting`
/// after [hydrate] fires its initial `_emit`) sit on `ConnectionState.waiting`
/// forever — `combineLatest` upstream of them never fills its slot for
/// this stream and the UI shows a perpetual spinner.
Stream<Map<String, List<PlexHomeUser>>> get stream {
@@ -74,6 +75,28 @@ class PlexHomeService {
return ctrl.stream;
}
/// Populate the in-memory snapshot from disk without starting live work.
///
/// Safe during the startup offline decision: this does not subscribe to
/// connection changes, install the refresh timer, or issue a refresh.
Future<void> hydrate() {
if (_disposed) return Future.value();
final pending = _hydrateFuture;
if (pending != null) return pending;
final epoch = _lifecycleEpoch;
final future = _hydrate(epoch).catchError((Object error, StackTrace stackTrace) {
_hydrateFuture = null;
Error.throwWithStackTrace(error, stackTrace);
});
_hydrateFuture = future;
return future;
}
/// Start observing connections and refreshing Plex Home users.
///
/// Hydration always completes first so [_onChange] cannot observe a
/// half-initialized cache or storage handle.
Future<void> start() {
if (_disposed || _started) return Future.value();
final pending = _startFuture;
@@ -90,11 +113,11 @@ class PlexHomeService {
/// Re-read per-connection Plex Home user caches from storage.
///
/// This is used after boot-time legacy migration. The service is started
/// This is used after boot-time legacy migration. The service is hydrated
/// before [ConnectionBootstrap] runs, so it may have already missed the
/// copied `plex_home_users_{connectionId}` cache and new connection row.
Future<void> reloadFromStorage() async {
await start();
await hydrate();
final epoch = _lifecycleEpoch;
if (!_isLifecycleCurrent(epoch)) return;
_storage ??= await StorageService.getInstance();
@@ -134,10 +157,11 @@ class PlexHomeService {
if (changed && _isLifecycleCurrent(epoch)) _emit();
}
Future<void> _start(int epoch) async {
Future<void> _hydrate(int epoch) async {
_storage ??= await StorageService.getInstance();
if (!_isLifecycleCurrent(epoch)) return;
await _reloadStorageCacheIfNeeded(_storage!);
if (!_isLifecycleCurrent(epoch)) return;
final initial = await _connections.list();
if (!_isLifecycleCurrent(epoch)) return;
@@ -154,8 +178,12 @@ class PlexHomeService {
}
}
_emit();
}
Future<void> _start(int epoch) async {
await hydrate();
if (!_isLifecycleCurrent(epoch)) return;
_connSub = _connections.watchConnections().listen(_onChange);
_refreshTimer = Timer.periodic(_refreshInterval, (_) => unawaited(_refreshAll()));
@@ -428,14 +456,14 @@ class PlexHomeService {
/// [connectionId]. Use this instead of [materializeFirstPlexHome] in
/// multi-account flows that already know which Plex account is active.
Future<PlexHome?> materializePlexHomeForConnection(String connectionId) async {
await start();
await hydrate();
return materializePlexHome(connectionId);
}
/// Convenience wrapper: materialize the home for the first Plex account
/// in [ConnectionRegistry] (the only one most users have).
Future<PlexHome?> materializeFirstPlexHome() async {
await start();
await hydrate();
final all = await _connections.list();
final first = all.whereType<PlexAccountConnection>().firstOrNull;
if (first == null) return null;
@@ -485,6 +513,7 @@ class PlexHomeService {
_connSub = null;
final pendingCommits = _commitBarriers.values.toList();
if (pendingCommits.isNotEmpty) await Future.wait(pendingCommits);
_hydrateFuture = null;
_startFuture = null;
if (!_controller.isClosed) await _controller.close();
_started = false;
+20 -1
View File
@@ -23,6 +23,9 @@ import '../widgets/auth_error_banner.dart';
import '../widgets/app_icon.dart';
import '../utils/provider_extensions.dart';
import '../utils/platform_detector.dart';
import '../utils/platform_http_client_stub.dart'
if (dart.library.io) '../utils/platform_http_client_io.dart'
show warmUpPlatformHttpClient;
import '../utils/snackbar_helper.dart';
import '../utils/update_dialog.dart';
import '../utils/video_player_navigation.dart';
@@ -558,7 +561,18 @@ class _MainScreenState extends State<MainScreen>
_hadProfiles = activeProfile.profiles.isNotEmpty;
activeProfile.addListener(_onActiveProfileChanged);
_plexHomeService = context.read<PlexHomeService>();
unawaited(_plexHomeService!.start());
// `start()` is the live/network entry point: it installs the connection
// watch, the refresh timer and an initial `_refreshAll()`. Hydration
// already happened from the provider's `create:`, so an offline launch
// keeps its cached Plex Home users without reaching the network.
// `_handleOfflineStatusChanged` starts it if we come online later.
if (!_isOffline) unawaited(_plexHomeService!.start());
// Cronet's first `CronetEngine.build()` costs ~460 ms of synchronous JNI
// work (Play services Dynamite + GMS HTTP flags) and used to land between
// `database_ready` and `credentials_loaded`, i.e. squarely on the path to
// this screen. Android clients start on the tuned IOClient and swap to
// Cronet once this completes.
unawaited(warmUpPlatformHttpClient());
final manager = context.read<MultiServerProvider>().serverManager;
// Read the binder so the Provider's `lazy: false` create has fired
// for sure; start only in online mode so explicit startup offline does
@@ -1332,6 +1346,11 @@ class _MainScreenState extends State<MainScreen>
// Ensure profile settings are warmed when coming back online
if (!_isOffline) {
// A launch that started offline skipped PlexHomeService's live/network
// side, so pick it up now that we have a network. `start()` is idempotent,
// so repeated offline/online transitions are free.
final plexHome = _plexHomeService ??= context.read<PlexHomeService>();
unawaited(plexHome.start());
unawaited(() async {
final mp = context.read<MultiServerProvider>();
final binder = context.read<ActiveProfileBinder>();
@@ -81,7 +81,10 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
final profileRegistry = context.read<ProfileRegistry>();
final plexHome = context.read<PlexHomeService>();
await plexHome.start();
// Cache-only: this picker reads `plexHome.current` immediately and must
// not start live refresh, which would reach the network from a screen the
// user can open while offline.
await plexHome.hydrate();
final results = await Future.wait([
pcRegistry.listAll(),
connRegistry.list(),
+13 -1
View File
@@ -4,6 +4,17 @@ 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
@@ -13,7 +24,7 @@ import 'app_logger.dart';
/// 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 {
class ManagedHttpClient extends http.BaseClient implements GracefulHttpClient {
ManagedHttpClient(this._inner, {required this.debugLabel, this.forceCloseOnDrainTimeout = false}) {
_instances.add(this);
}
@@ -61,6 +72,7 @@ class ManagedHttpClient extends http.BaseClient {
}
}
@override
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) {
_closing = true;
if (_innerClosed) return Future<void>.value();
+2 -2
View File
@@ -237,8 +237,8 @@ class MediaServerHttpClient {
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) async {
_closing = true;
_abortActiveRequests();
if (_client case final ManagedHttpClient managed) {
await managed.closeGracefully(drainTimeout: drainTimeout);
if (_client case final GracefulHttpClient graceful) {
await graceful.closeGracefully(drainTimeout: drainTimeout);
} else {
_client.close();
}
+93 -22
View File
@@ -13,22 +13,110 @@ 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');
bool _loggedPlatformClient = false;
final Set<String> _loggedPlatformClients = <String>{};
void _logPlatformClient(String platform, String client) {
if (_loggedPlatformClient) return;
_loggedPlatformClient = true;
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).
http.Client _createIoClient(String debugLabel, {bool tuned = false}) {
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.
@@ -41,24 +129,7 @@ http.Client _createIoClient(String debugLabel, {bool tuned = false}) {
http.Client createPlatformClient() {
if (Platform.isAndroid) {
if (!_cronetBroken) {
try {
_sharedEngine ??= CronetEngine.build(
cacheMode: CacheMode.memory,
cacheMaxSize: 2 * 1024 * 1024,
enableBrotli: true,
enableHttp2: true,
);
_logPlatformClient('android', 'CronetClient');
return ManagedHttpClient(CronetClient.fromCronetEngine(_sharedEngine!), debugLabel: 'CronetClient');
} catch (e, st) {
_cronetBroken = true;
_sharedEngine = null;
appLogger.w('CronetClient init failed, falling back to IOClient', error: e, stackTrace: st);
}
}
_logPlatformClient('android', 'IOClient (Android fallback)');
return _createIoClient('IOClient (Android fallback)', tuned: true);
return AndroidPlatformHttpClient();
}
if (Platform.isIOS && _tvosBuild) {
_logPlatformClient('tvos', 'IOClient (tvOS tuned)');
+3
View File
@@ -5,3 +5,6 @@ import 'package:http/http.dart' as http;
http.Client createPlatformClient() => throw UnsupportedError('No platform HTTP client available');
http.Client createPlexApiClient() => throw UnsupportedError('No platform HTTP client available');
/// No-op: only the `dart:io` implementation has an engine to warm.
Future<void> warmUpPlatformHttpClient() async {}
+76
View File
@@ -248,6 +248,82 @@ void main() {
expect(service.current[acct.id]!.first.uuid, 'seeded-uuid');
});
// `start()` used to be the only entry point, and it was called from a
// provider `create:` — so it began network refresh before SetupScreen had
// decided whether the app should go straight offline. `hydrate()` is the
// disk-only half, safe to call before that decision.
test('hydrate loads the cache without issuing any network fetch', () async {
await storage.savePlexHomeUsersCache('plex.hydrate', [_user('cached-uuid').toJson()]);
final acct = _account('plex.hydrate');
await connections.upsert(acct);
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
await service.hydrate();
// Give any stray unawaited refresh a chance to reach the fetcher.
await Future<void>.delayed(Duration.zero);
expect(service.current[acct.id]!.single.uuid, 'cached-uuid');
expect(fetcher.requests, isEmpty, reason: 'hydration must not touch the network');
});
test('hydrate still satisfies the stream replay contract', () async {
// `stream` re-emits on listen so a late listener behind a combineLatest
// does not sit on ConnectionState.waiting forever. That has to hold even
// when the live/network side never starts.
await storage.savePlexHomeUsersCache('plex.replay', [_user('replay-uuid').toJson()]);
final acct = _account('plex.replay');
await connections.upsert(acct);
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
await service.hydrate();
final first = await service.stream.first;
expect(first[acct.id]!.single.uuid, 'replay-uuid');
expect(fetcher.requests, isEmpty);
});
test('concurrent hydrate calls share one pass and start implies hydration', () async {
await storage.savePlexHomeUsersCache('plex.coalesce', [_user('coalesce-uuid').toJson()]);
final acct = _account('plex.coalesce');
await connections.upsert(acct);
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
// Provider create and a picker opening at the same time.
await Future.wait([service.hydrate(), service.hydrate(), service.hydrate()]);
expect(service.current[acct.id]!.single.uuid, 'coalesce-uuid');
expect(fetcher.requests, isEmpty);
// start() is self-sufficient: it implies hydration and then goes live.
await service.start();
await fetcher.waitForCount(1);
expect(fetcher.requests, hasLength(1), reason: 'the live side does refresh');
expect(service.current[acct.id]!.single.uuid, 'coalesce-uuid');
});
test('reloadFromStorage picks up caches written after startup', () async {
final refreshBlocker = Completer<List<PlexHomeUser>>();
service = PlexHomeService(
@@ -64,10 +64,10 @@ void main() {
),
);
expect(plexHome.starts, hasLength(1));
expect(plexHome.hydrations, hasLength(1));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
plexHome.starts.first.completeError(StateError('load failed'));
plexHome.hydrations.first.completeError(StateError('load failed'));
await tester.pumpAndSettle();
expect(find.text(t.profiles.borrowLoadFailed), findsOneWidget);
@@ -77,11 +77,11 @@ void main() {
await tester.tap(find.text(t.common.retry));
await tester.pump();
expect(plexHome.starts, hasLength(2));
expect(plexHome.hydrations, hasLength(2));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text(t.profiles.borrowLoadFailed), findsNothing);
plexHome.starts.last.complete();
plexHome.hydrations.last.complete();
await tester.pumpAndSettle();
expect(find.text(t.profiles.borrowEmpty), findsOneWidget);
@@ -92,12 +92,15 @@ void main() {
class _ControlledPlexHomeService extends PlexHomeService {
_ControlledPlexHomeService({required super.connections, required super.profileConnections, required super.storage});
final Queue<Completer<void>> starts = Queue();
/// The picker reads `current` straight after awaiting, so it needs the disk
/// cache only and deliberately does not start live refresh — gate the same
/// call the screen makes.
final Queue<Completer<void>> hydrations = Queue();
@override
Future<void> start() {
Future<void> hydrate() {
final completer = Completer<void>();
starts.add(completer);
hydrations.add(completer);
return completer.future;
}
}
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/utils/managed_http_client.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_http_client_io.dart';
void main() {
group('MediaServerHttpClient shutdown', () {
@@ -110,6 +111,32 @@ void main() {
expect(File(filePath).existsSync(), isFalse, reason: 'a cancelled download must not commit the final file');
expect(File('$filePath.download').existsSync(), isFalse, reason: 'the partial temp file must be cleaned up');
});
test('closeGracefully awaits a delegating GracefulHttpClient drain', () async {
final transport = _ManualDrainClient();
final client = MediaServerHttpClient(client: transport, baseUrl: 'https://example.test/');
var closed = false;
final closing = client.closeGracefully().then((_) => closed = true);
await Future<void>.delayed(Duration.zero);
expect(transport.drainRequested, isTrue);
expect(closed, isFalse, reason: 'must await the delegate drain, not fall back to fire-and-forget close()');
transport.finishDrain();
await closing;
expect(closed, isTrue);
});
test('AndroidPlatformHttpClient keeps the graceful-drain contract and rejects sends once closing', () async {
final client = AndroidPlatformHttpClient();
expect(client, isA<GracefulHttpClient>(), reason: 'per-server awaited drains dispatch on GracefulHttpClient');
await client.closeGracefully(drainTimeout: Duration.zero);
expect(
() => client.send(http.Request('GET', Uri.parse('https://example.test/library/sections'))),
throwsA(isA<http.ClientException>()),
);
});
});
}
@@ -150,3 +177,24 @@ class _HangingBodyClient extends http.BaseClient {
await body.close();
}
}
/// Delegating client (the AndroidPlatformHttpClient shape) whose drain
/// completes only when the test says so.
class _ManualDrainClient extends http.BaseClient implements GracefulHttpClient {
final _drained = Completer<void>();
bool drainRequested = false;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) => throw UnimplementedError();
@override
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) {
drainRequested = true;
return _drained.future;
}
void finishDrain() => _drained.complete();
@override
void close() {}
}