Plezy carried three native HTTP clients on the assumption that they beat Dart's own. Benchmarked against real Plex and Jellyfin servers on macOS, Windows, Linux and three Android devices, two of them do not. Cronet loses on every request shape the app issues: 8.1 vs 83.8 MiB/s on a LAN body read, 84 vs 150 req/s on an artwork fan-out, 14.3 vs 10.7 ms on sequential API calls. It also fails a 60-way fan-out outright with net::ERR_CACHE_WRITE_FAILURE under the 2 MiB memory cache we configured, and cost 70-705 ms of CronetEngine.build on first use. Paying that build off the critical path is the only reason AndroidPlatformHttpClient, warmUpPlatformHttpClient and the per-request delegate swap existed; all three go away with it. CupertinoClient had no measured advantage either, losing the TLS fan-out 44 vs 60 req/s, and no reported issue ever justified it. tvOS already shipped the dart:io client, so Apple platforms now agree with it. WinHttpClient stays. WINHTTP_OPTION_IPV6_FAST_FALLBACK (#1128) has no dart:io equivalent, and it brings the system proxy and the Schannel trust store. The pool tuning becomes unconditional. It was opt-in behind usePlexApiClient so generic tracker and auth clients stayed disposable, but every dart:io client has carried connectionTimeout and forceCloseOnDrainTimeout since #1972, so tuned and untuned already share shutdown semantics and the flag only cost throughput: 12 connections per host with a 90s idle measured ~4x the dart:io default on a 60-way fan-out on Linux and ~2x on Android. media3-datasource-cronet and cronet-embedded stay. They back ExoPlayer's CronetDataSource independently of package:cronet_http. Refs #2140.
189 lines
6.6 KiB
Dart
189 lines
6.6 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';
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
|
|
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 [GracefulHttpClient] 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() {}
|
|
}
|