tvOS logs flood with "HTTP client drain timed out" / "close deferred" warning pairs after every Plex endpoint race, and each sweep leaks the losing probes' sockets for the OS connect timeout (~75 s of SYN retries on Darwin). A request stuck in TCP connect cannot be aborted: package:http's IOClient only registers the abort handler once openUrl completes, so the graceful drain never finishes and ManagedHttpClient defers the inner close that would have reclaimed the socket. Fix at both layers: every IOClient now gets an explicit HttpClient.connectionTimeout matching MediaServerTimeouts.connect, and ManagedHttpClient gains an opt-in forceCloseOnDrainTimeout escalation used by all dart:io-backed clients — close(force: true) promptly fails in-flight requests, unlike the native-callback clients (CupertinoClient) the deferral exists for. close #1972
222 lines
7.3 KiB
Dart
222 lines
7.3 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:plezy/utils/abortable_http_request.dart';
|
|
import 'package:plezy/utils/managed_http_client.dart';
|
|
|
|
void main() {
|
|
group('ManagedHttpClient', () {
|
|
test('preserves headers added by request finalization', () async {
|
|
final inner = _CapturingClient();
|
|
final client = ManagedHttpClient(inner, debugLabel: 'test');
|
|
addTearDown(() => client.closeGracefully(drainTimeout: Duration.zero));
|
|
|
|
final request = http.MultipartRequest('POST', Uri.parse('https://example.test/upload'))
|
|
..fields['title'] = 'Movie';
|
|
final response = await client.send(request);
|
|
await response.stream.drain<void>();
|
|
|
|
expect(inner.headers?['content-type'], startsWith('multipart/form-data; boundary='));
|
|
expect(inner.body, isNotEmpty);
|
|
});
|
|
|
|
test('closeGracefully aborts and cancels an active streamed response before closing inner client', () async {
|
|
final inner = _StreamingClient();
|
|
final client = ManagedHttpClient(inner, debugLabel: 'test');
|
|
addTearDown(() async {
|
|
await client.closeGracefully(drainTimeout: Duration.zero);
|
|
await inner.dispose();
|
|
});
|
|
|
|
final response = await client.send(http.Request('GET', Uri.parse('https://example.test/slow')));
|
|
|
|
await client.closeGracefully(drainTimeout: const Duration(milliseconds: 100));
|
|
|
|
expect(inner.closeCount, 1);
|
|
expect(inner.responseCancelled, isTrue);
|
|
await expectLater(inner.abortTrigger, completes);
|
|
await expectLater(response.stream.toList(), completion(isEmpty));
|
|
});
|
|
|
|
test('closeGracefully can retry after a drain timeout', () async {
|
|
final inner = _DeferredSendClient();
|
|
final client = ManagedHttpClient(inner, debugLabel: 'test');
|
|
addTearDown(() => client.closeGracefully(drainTimeout: Duration.zero));
|
|
|
|
final responseFuture = client.send(http.Request('GET', Uri.parse('https://example.test/slow')));
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
await client.closeGracefully(drainTimeout: const Duration(milliseconds: 1));
|
|
expect(inner.closeCount, 0);
|
|
|
|
var retryCompleted = false;
|
|
final retryClose = client.closeGracefully(drainTimeout: const Duration(seconds: 1));
|
|
unawaited(retryClose.whenComplete(() => retryCompleted = true));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(retryCompleted, isFalse);
|
|
|
|
inner.completeWithEmptyResponse();
|
|
final response = await responseFuture;
|
|
await response.stream.drain<void>();
|
|
await retryClose;
|
|
|
|
expect(inner.closeCount, 1);
|
|
});
|
|
|
|
test('force-closes inner client when opted in and the drain times out', () async {
|
|
final inner = _DeferredSendClient();
|
|
final client = ManagedHttpClient(inner, debugLabel: 'test', forceCloseOnDrainTimeout: true);
|
|
|
|
final responseFuture = client.send(http.Request('GET', Uri.parse('https://example.test/stuck')));
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
await client.closeGracefully(drainTimeout: const Duration(milliseconds: 1));
|
|
expect(inner.closeCount, 1);
|
|
await expectLater(inner.abortTrigger, completes);
|
|
|
|
// Idempotent: the inner client is closed exactly once.
|
|
await client.closeGracefully(drainTimeout: const Duration(milliseconds: 1));
|
|
expect(inner.closeCount, 1);
|
|
|
|
// A transport that completes late (a real dart:io client fails the
|
|
// request when force-closed) still settles cleanly.
|
|
inner.completeWithEmptyResponse();
|
|
final response = await responseFuture;
|
|
await response.stream.drain<void>();
|
|
});
|
|
|
|
test('preserves final response URL metadata', () async {
|
|
final finalUrl = Uri.parse('https://example.test/final');
|
|
final inner = _UrlResponseClient(finalUrl);
|
|
final client = ManagedHttpClient(inner, debugLabel: 'test');
|
|
addTearDown(() => client.closeGracefully(drainTimeout: Duration.zero));
|
|
|
|
final response = await client.send(http.Request('GET', Uri.parse('https://example.test/start')));
|
|
|
|
expect(response, isA<http.BaseResponseWithUrl>().having((r) => r.url, 'url', finalUrl));
|
|
await response.stream.drain<void>();
|
|
});
|
|
});
|
|
|
|
group('sendAbortableHttpRequest', () {
|
|
test('aborts the underlying request when its timeout fires', () async {
|
|
final inner = _HangingClient();
|
|
addTearDown(inner.close);
|
|
|
|
await expectLater(
|
|
sendAbortableHttpRequest(
|
|
inner,
|
|
'GET',
|
|
Uri.parse('https://example.test/slow'),
|
|
timeout: const Duration(milliseconds: 1),
|
|
operation: 'slow request',
|
|
),
|
|
throwsA(isA<TimeoutException>().having((e) => e.message, 'message', 'slow request timed out')),
|
|
);
|
|
|
|
await expectLater(inner.abortTrigger, completes);
|
|
});
|
|
});
|
|
}
|
|
|
|
class _CapturingClient extends http.BaseClient {
|
|
Map<String, String>? headers;
|
|
List<int>? body;
|
|
|
|
@override
|
|
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
|
headers = Map.of(request.headers);
|
|
body = await request.finalize().toBytes();
|
|
return http.StreamedResponse(const Stream<List<int>>.empty(), 200, request: request);
|
|
}
|
|
}
|
|
|
|
class _StreamingClient extends http.BaseClient {
|
|
_StreamingClient() {
|
|
responseController = StreamController<List<int>>(
|
|
onCancel: () {
|
|
responseCancelled = true;
|
|
},
|
|
);
|
|
}
|
|
|
|
late final StreamController<List<int>> responseController;
|
|
Future<void>? abortTrigger;
|
|
var closeCount = 0;
|
|
var responseCancelled = false;
|
|
|
|
@override
|
|
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
|
abortTrigger = (request as http.Abortable).abortTrigger;
|
|
return http.StreamedResponse(responseController.stream, 200, request: request);
|
|
}
|
|
|
|
@override
|
|
void close() {
|
|
closeCount += 1;
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
await responseController.close();
|
|
}
|
|
}
|
|
|
|
class _DeferredSendClient extends http.BaseClient {
|
|
final _response = Completer<http.StreamedResponse>();
|
|
http.BaseRequest? request;
|
|
Future<void>? abortTrigger;
|
|
var closeCount = 0;
|
|
|
|
@override
|
|
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
|
this.request = request;
|
|
abortTrigger = (request as http.Abortable).abortTrigger;
|
|
return _response.future;
|
|
}
|
|
|
|
void completeWithEmptyResponse() {
|
|
_response.complete(http.StreamedResponse(const Stream<List<int>>.empty(), 200, request: request));
|
|
}
|
|
|
|
@override
|
|
void close() {
|
|
closeCount += 1;
|
|
}
|
|
}
|
|
|
|
class _UrlResponseClient extends http.BaseClient {
|
|
_UrlResponseClient(this.url);
|
|
|
|
final Uri url;
|
|
|
|
@override
|
|
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
|
return _ResponseWithUrl(const Stream<List<int>>.empty(), 200, url: url, request: request);
|
|
}
|
|
}
|
|
|
|
class _ResponseWithUrl extends http.StreamedResponse implements http.BaseResponseWithUrl {
|
|
_ResponseWithUrl(super.stream, super.statusCode, {required this.url, super.request});
|
|
|
|
@override
|
|
final Uri url;
|
|
}
|
|
|
|
class _HangingClient extends http.BaseClient {
|
|
final _response = Completer<http.StreamedResponse>();
|
|
Future<void>? abortTrigger;
|
|
var closed = false;
|
|
|
|
@override
|
|
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
|
abortTrigger = (request as http.Abortable).abortTrigger;
|
|
return _response.future;
|
|
}
|
|
|
|
@override
|
|
void close() {
|
|
closed = true;
|
|
}
|
|
}
|