Files
plezy/test/utils/media_server_http_client_shutdown_test.dart
T
edde746 0c5308cfa1 refactor(networking): drop Cronet and NSURLSession for the tuned dart:io client
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.
2026-08-27 09:21:48 +02:00

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() {}
}