Files
plezy/test/utils/media_server_http_client_shutdown_test.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

201 lines
7.2 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
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', () {
test('rejects new requests as a cancellation, not a transient failure', () async {
final client = MediaServerHttpClient(
client: ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test'),
baseUrl: 'https://example.test/',
);
client.close();
await expectLater(
client.get('library/sections'),
throwsA(
isA<MediaServerHttpException>()
.having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)
.having((e) => e.isTransient, 'isTransient', isFalse),
),
);
});
test('the layer beneath reports the same shutdown as a transient connection error', () async {
final managed = ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test');
await managed.closeGracefully(drainTimeout: Duration.zero);
await expectLater(
managed.send(http.Request('GET', Uri.parse('https://example.test/library/sections'))),
throwsA(
isA<http.ClientException>()
.having(
(e) => MediaServerHttpException.from(e).type,
'mapped type',
MediaServerHttpErrorType.connectionError,
)
.having((e) => MediaServerHttpException.from(e).isTransient, 'mapped isTransient', isTrue),
),
);
});
test('aborts requests already in flight at the transport', () async {
final transport = _AbortAwareClient();
final client = MediaServerHttpClient(client: transport, baseUrl: 'https://example.test/');
final pending = client.get('library/sections');
await Future<void>.delayed(Duration.zero);
client.close();
await expectLater(transport.abortTrigger, completes);
await expectLater(
pending,
throwsA(isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)),
);
});
test('shutdown during an already-streaming body surfaces as cancelled, not an empty success', () async {
final inner = _HangingBodyClient();
final client = MediaServerHttpClient(
client: ManagedHttpClient(inner, debugLabel: 'test'),
baseUrl: 'https://example.test/',
);
addTearDown(inner.dispose);
final pending = client.get('library/sections');
await Future<void>.delayed(Duration.zero);
inner.body.add([1, 2, 3]);
await Future<void>.delayed(Duration.zero);
final closing = client.closeGracefully(drainTimeout: const Duration(milliseconds: 100));
await expectLater(
pending,
throwsA(isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)),
);
await closing;
});
test('downloadFile cancellation fails without committing a renamed file', () async {
final dir = await Directory.systemTemp.createTemp('plezy_download_cancel_test');
addTearDown(() => dir.delete(recursive: true));
final filePath = '${dir.path}${Platform.pathSeparator}subtitle.srt';
final inner = _HangingBodyClient();
final client = MediaServerHttpClient(
client: ManagedHttpClient(inner, debugLabel: 'test'),
baseUrl: 'https://example.test/',
);
addTearDown(inner.dispose);
final pending = client.downloadFile('media/subtitle.srt', filePath);
await Future<void>.delayed(Duration.zero);
inner.body.add([1, 2, 3]);
// Let the partial chunk reach the sink before the abort lands.
await Future<void>.delayed(Duration.zero);
final closing = client.closeGracefully(drainTimeout: const Duration(milliseconds: 100));
await expectLater(
pending,
throwsA(isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)),
);
await closing;
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>()),
);
});
});
}
class _AbortAwareClient extends http.BaseClient {
final _response = Completer<http.StreamedResponse>();
late final Future<void> abortTrigger;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
final trigger = (request as http.Abortable).abortTrigger!;
abortTrigger = trigger;
unawaited(
trigger.whenComplete(() {
if (!_response.isCompleted) _response.completeError(http.RequestAbortedException(request.url));
}),
);
return _response.future;
}
@override
void close() {}
}
/// Returns a 200 whose body stays open until the test aborts it, so
/// cancellation lands while (or before) the consumer is reading.
class _HangingBodyClient extends http.BaseClient {
final StreamController<List<int>> body = StreamController<List<int>>();
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
return http.StreamedResponse(body.stream, 200);
}
@override
void close() {}
Future<void> dispose() async {
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() {}
}