fix(images): release artwork slots on non-200 responses

This commit is contained in:
edde746
2026-07-04 17:31:52 +02:00
parent 536e8e3d2a
commit 30de8be2cd
2 changed files with 137 additions and 3 deletions
+104
View File
@@ -0,0 +1,104 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/services/image_cache_service.dart';
/// Pins the artwork request limiter's permit lifecycle. CE's cache manager
/// abandons non-200/202 responses without listening to the body, so the
/// wrapper must release those slots itself or the limiter wedges after a
/// handful of stale-thumb 404s (#1473).
void main() {
const timeout = Duration(seconds: 5);
http.Request get(String path) => http.Request('GET', Uri.parse('https://example.invalid$path'));
MockClient mixedClient() => MockClient((request) async {
if (request.url.path.contains('missing')) {
return http.Response('not found', 404, headers: {'x-marker': 'err'});
}
return http.Response('poster-bytes', 200);
});
test('error burst does not wedge the limiter (#1473)', () async {
final client = createArtworkHttpClientForTest(mixedClient(), maxConcurrent: 2);
// More failures than permits, bodies deliberately never read — mirrors
// CE throwing on the status without listening to the stream.
for (var i = 0; i < 5; i++) {
final response = await client.send(get('/missing/$i')).timeout(timeout);
expect(response.statusCode, 404);
expect(response.headers['x-marker'], 'err');
}
final ok = await client.send(get('/poster')).timeout(timeout);
expect(ok.statusCode, 200);
expect(await ok.stream.bytesToString().timeout(timeout), 'poster-bytes');
});
test('successful downloads still hold a slot until the body is drained', () async {
final client = createArtworkHttpClientForTest(mixedClient(), maxConcurrent: 1);
final first = await client.send(get('/poster')).timeout(timeout);
var secondDone = false;
final second = client.send(get('/poster')).then((response) async {
secondDone = true;
await response.stream.drain<void>();
});
await pumpEventQueue();
expect(secondDone, isFalse, reason: 'slot must stay held while the first body is unread');
await first.stream.drain<void>();
await second.timeout(timeout);
expect(secondDone, isTrue);
});
test('error bodies are drained so the transport can reclaim the connection', () async {
final listened = Completer<void>();
final body = StreamController<List<int>>(onListen: () => listened.complete());
unawaited(body.close());
final client = createArtworkHttpClientForTest(
MockClient.streaming((request, _) async => http.StreamedResponse(body.stream, 404)),
maxConcurrent: 2,
);
final response = await client.send(get('/missing')).timeout(timeout);
expect(response.statusCode, 404);
await listened.future.timeout(timeout);
});
test('a throwing send releases its slot', () async {
var failures = 0;
final client = createArtworkHttpClientForTest(
MockClient((request) async {
if (failures < 2) {
failures++;
throw http.ClientException('boom');
}
return http.Response('poster-bytes', 200);
}),
maxConcurrent: 1,
);
for (var i = 0; i < 2; i++) {
await expectLater(client.send(get('/poster')).timeout(timeout), throwsA(isA<http.ClientException>()));
}
final ok = await client.send(get('/poster')).timeout(timeout);
expect(ok.statusCode, 200);
});
test('cancelling a successful body releases its slot', () async {
final client = createArtworkHttpClientForTest(mixedClient(), maxConcurrent: 1);
final first = await client.send(get('/poster')).timeout(timeout);
final subscription = first.stream.listen((_) {});
await subscription.cancel();
final ok = await client.send(get('/poster')).timeout(timeout);
expect(ok.statusCode, 200);
await ok.stream.drain<void>();
});
}