Files
plezy/test/utils/http_lifecycle_test.dart
T
edde746 e1b488f860 fix(lifecycle): force-close IOClients stuck in unabortable TCP connects
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
2026-08-17 08:08:45 +02:00

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;
}
}