Files
buzz/mobile/test/shared/widgets/ios_media_carousel_test.dart
Tom Brow 2e0abbcc0c fix(mobile): let slow image downloads finish (#8881)
On a slow link, a large image could never appear. Every image fetch was
aborted 30s after it started, even while bytes were still arriving, and
a failed fetch then sat on a 30s cooldown before it could be retried. At
240 kbps, anything over about 900 KB could not finish. On a shaped
iPhone SE, a 1.45 MB avatar was fetched, aborted at 30s and never shown.

- **Abort stalls, not slow downloads.** A fetch is now aborted when no
headers or body bytes arrive for 20s, so a dead connection still frees
up quickly. A download that keeps making progress can run for up to 5
minutes, so a server that trickles bytes cannot hold a connection
forever. The 20 MiB response cap still bounds its size.
- **Cancel previews scrolled past in the iOS carousel.** The carousel
loads at most four previews at a time, and a preview scrolled past kept
its slot until its download ended. With the longer limit, four slow
previews could block the visible one for minutes. Now the carousel drops
a cancelled preview's pending image-cache entry, and with no one left
listening, the image loader aborts its HTTP request. Scrolling back
starts a fresh download with no failure cooldown. A download that
another consumer, such as the full-screen viewer or a gallery prefetch,
is still listening to keeps going, and its finished image is still
cached. Closing the carousel aborts its pending previews the same way.
- **Cancel gallery prefetches that leave the window.** The message
gallery prefetches a few pages around the current one with two workers.
Each prefetch used to hold its download until it ended, which also kept
the carousel's cancel from aborting it. Now a prefetch that leaves the
window, or whose gallery goes away, lets go and aborts the same way.

### Validation

Added tests (each fails without its fix):

- A body that trickles in for longer than the stall timeout still loads,
and one that never ends is aborted at the overall cap.
- In the iOS carousel, previews scrolled past abort their stalled
downloads (before headers and mid-body), the next preview loads, and
scrolling back starts a new download. The test fails if the carousel
doesn't evict, if the loader doesn't abort, or if the abort leaves a
cooldown.
- Closing the carousel aborts its stalled previews.
- In a message gallery, jumping to the end and back aborts both
in-flight prefetches and moves the workers to the new window.
- Abandoning a load another consumer still waits for keeps the finished
image cached with no second fetch; abandoning one nobody else waits for
aborts it.
- Rapid scrolling releases each cancelled carousel load at once, and
cancelled loads never reach UIKit.

The full mobile suite passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:57:11 -08:00

395 lines
13 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:buzz/shared/widgets/ios_media_carousel.dart';
import 'package:buzz/shared/relay/media_auth.dart';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
for (final stallBody in [false, true]) {
testWidgets(
'previews scrolled past abort their downloads while ${stallBody ? "bodies" : "headers"} stall',
(tester) async {
final client = (await tester.runAsync(() async => _TrackingClient()))!;
final server = await tester.runAsync(
() => HttpServer.bind(InternetAddress.loopbackIPv4, 0),
);
addTearDown(() async {
client.close();
await server!.close(force: true);
MediaImageProvider.debugResetCooldowns();
});
final base = 'http://127.0.0.1:${server!.port}';
final auth = MediaGetAuthService(baseUrl: base, nsec: null);
await tester.runAsync(() async {
server.listen((request) {
if (request.uri.path == '/4') {
request.response.statusCode = 404;
unawaited(request.response.close());
} else if (stallBody) {
request.response.contentLength = 100;
request.response.add([0]);
unawaited(request.response.flush());
}
// The first four never finish and never time out within the
// test: only the carousel's cancellation can release them.
});
});
final harness = await _mount(
tester,
List.generate(
5,
(index) => MediaImageProvider(
url: '$base/$index',
auth: auth,
client: client,
stallTimeout: const Duration(minutes: 1),
),
),
);
await tester.runAsync(() async {
for (var index = 0; index < 4; index++) {
await harness.send('subscribeImage', {
'id': '$index',
'index': index,
});
await harness.send('cancelImage', '$index');
}
await client.retired.future.timeout(const Duration(seconds: 5));
await harness.send('subscribeImage', {'id': 'latest', 'index': 4});
await harness.waitForFrames(1);
// Scrolling back starts a fresh download: an abandoned load is not
// a failure, so it leaves no cooldown behind.
await harness.send('subscribeImage', {'id': 'back', 'index': 0});
for (var i = 0; i < 100 && client.started < 6; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
expect(client.aborted, 4);
expect(client.started, 6);
expect(harness.frames, [
{'id': 'latest', 'bytes': null},
]);
await tester.pumpWidget(const SizedBox());
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
}
testWidgets('closing the carousel aborts its pending downloads', (
tester,
) async {
final client = (await tester.runAsync(() async => _TrackingClient()))!;
final server = await tester.runAsync(
() => HttpServer.bind(InternetAddress.loopbackIPv4, 0),
);
addTearDown(() async {
client.close();
await server!.close(force: true);
MediaImageProvider.debugResetCooldowns();
});
final base = 'http://127.0.0.1:${server!.port}';
final auth = MediaGetAuthService(baseUrl: base, nsec: null);
// Requests never get a response: only disposal can release them.
await tester.runAsync(() async => server.listen((_) {}));
final harness = await _mount(
tester,
List.generate(
4,
(index) => MediaImageProvider(
url: '$base/$index',
auth: auth,
client: client,
stallTimeout: const Duration(minutes: 1),
),
),
);
await tester.runAsync(() async {
for (var index = 0; index < 4; index++) {
await harness.send('subscribeImage', {'id': '$index', 'index': index});
}
for (var i = 0; i < 100 && client.started < 4; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
expect(client.started, 4);
await tester.pumpWidget(const SizedBox());
await tester.runAsync(
() => client.retired.future.timeout(const Duration(seconds: 5)),
);
expect(client.aborted, 4);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
testWidgets('native callbacks and disposal release subscribed images', (
tester,
) async {
final provider = _TestImage();
final harness = await _mount(tester, [provider]);
await harness.send('open', 0);
await harness.send('open', 2);
await harness.send('page', 0);
await harness.send('scroll', 21.5);
expect(harness.opened, [0]);
expect(harness.pages, [0]);
expect(harness.offsets, [21.5]);
await harness.send('subscribeImage', {'id': 'a', 'index': 0});
expect(provider.completer.listeners, hasLength(1));
await tester.pumpWidget(const SizedBox());
expect(provider.completer.listeners, isEmpty);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
testWidgets('multi-image native gallery forwards continuing animation frames', (
tester,
) async {
final animated = _TestImage();
final still = _TestImage();
final harness = await _mount(tester, [animated, still]);
await harness.send('subscribeImage', {'id': 'animated', 'index': 0});
await harness.send('subscribeImage', {'id': 'still', 'index': 1});
final decoded = await tester.runAsync(() async {
// Two 1x1 GIF frames with different palette indices: red, then blue.
final codec = await ui.instantiateImageCodec(
base64Decode(
'R0lGODlhAQABAIAAAP8AAAAA/yH5BAAKAAAALAAAAAABAAEAAAICRAEAIfkEAAoAAAAsAAAAAAEAAQAAAgJMAQA7',
),
);
try {
expect(codec.frameCount, 2);
return [
(await codec.getNextFrame()).image,
(await codec.getNextFrame()).image,
];
} finally {
codec.dispose();
}
});
await tester.runAsync(() async {
animated.completer.emit(decoded![0]);
await harness.waitForFrames(1);
animated.completer.emit(decoded[1]);
await harness.waitForFrames(2);
});
expect(harness.frames.map((f) => f['id']), ['animated', 'animated']);
expect(
harness.frames[0]['bytes'],
isNot(orderedEquals(harness.frames[1]['bytes'] as Uint8List)),
);
await harness.send('cancelImage', 'animated');
expect(animated.completer.listeners, isEmpty);
final count = harness.frames.length;
final green = await tester.runAsync(() => _frame(Colors.green));
animated.completer.emit(green!);
await tester.pump();
expect(harness.frames, hasLength(count));
await tester.pumpWidget(const SizedBox());
expect(still.completer.listeners, isEmpty);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
testWidgets('rapid reuse releases cancelled loads at once', (tester) async {
final providers = List.generate(20, (_) => _TestImage());
final harness = await _mount(tester, providers);
for (var index = 0; index < 20; index++) {
await harness.send('subscribeImage', {'id': '$index', 'index': index});
if (index < 19) await harness.send('cancelImage', '$index');
}
// A cancelled load stops listening, which lets MediaImageProvider abort
// its download, so it never holds a slot the visible cell needs.
expect(providers.every((p) => p.resolutions == 1), isTrue);
expect(
providers.take(19).every((p) => p.completer.listeners.isEmpty),
isTrue,
);
expect(providers[19].completer.listeners, hasLength(1));
final image = await tester.runAsync(() => _frame(Colors.red));
providers[0].completer.emit(image!);
await tester.pump();
expect(
harness.frames,
isEmpty,
reason: 'Cancelled loads must never reach UIKit',
);
await tester.pumpWidget(const SizedBox());
expect(providers.every((p) => p.completer.listeners.isEmpty), isTrue);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
}
Future<ui.Image> _frame(Color color) async {
final recorder = ui.PictureRecorder();
Canvas(
recorder,
).drawRect(const Rect.fromLTWH(0, 0, 2, 2), Paint()..color = color);
final picture = recorder.endRecording();
try {
return await picture.toImage(2, 2);
} finally {
picture.dispose();
}
}
Future<_Harness> _mount(
WidgetTester tester,
List<ImageProvider<Object>> providers,
) async {
final harness = _Harness(tester);
final messenger = tester.binding.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(SystemChannels.platform_views, (
call,
) async {
if (call.method == 'create') {
final id = (call.arguments as Map)['id'];
harness.channel = MethodChannel('${IosMediaCarousel.viewType}/$id');
messenger.setMockMethodCallHandler(harness.channel, (call) async {
if (call.method == 'imageFrame') {
harness.frames.add(Map<String, Object?>.from(call.arguments as Map));
harness.changed?.complete();
harness.changed = null;
}
return null;
});
}
return null;
});
addTearDown(() {
messenger.setMockMethodCallHandler(SystemChannels.platform_views, null);
messenger.setMockMethodCallHandler(harness.channel, null);
});
await tester.pumpWidget(
MaterialApp(
home: SizedBox(
width: 300,
height: 200,
child: IosMediaCarousel(
providers: providers,
labels: List.generate(providers.length, (i) => 'Photo $i'),
width: 300,
gutter: 20,
gap: 4,
trailingPadding: 20,
radius: 16,
onOpen: harness.opened.add,
onScroll: harness.offsets.add,
onPageChanged: harness.pages.add,
),
),
),
);
await tester.pump();
return harness;
}
class _Harness {
_Harness(this.tester);
final WidgetTester tester;
late MethodChannel channel;
final opened = <int>[];
final pages = <int>[];
final offsets = <double>[];
final frames = <Map<String, Object?>>[];
Completer<void>? changed;
Future<void> waitForFrames(int count) async {
while (frames.length < count) {
changed = Completer<void>();
await changed!.future.timeout(const Duration(seconds: 5));
}
}
Future<void> send(String method, Object argument) async {
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(MethodCall(method, argument)),
(_) {},
);
}
}
class _TestImage extends ImageProvider<_TestImage> {
_TestImage() {
final handle = completer.keepAlive();
addTearDown(handle.dispose);
}
final completer = _TestCompleter();
int resolutions = 0;
@override
Future<_TestImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture(this);
@override
void resolveStreamForKey(
ImageConfiguration configuration,
ImageStream stream,
_TestImage key,
ImageErrorListener handleError,
) {
resolutions++;
stream.setCompleter(completer);
}
}
class _TestCompleter extends ImageStreamCompleter {
final listeners = <ImageStreamListener>{};
@override
void addListener(ImageStreamListener listener) {
listeners.add(listener);
super.addListener(listener);
}
@override
void removeListener(ImageStreamListener listener) {
listeners.remove(listener);
super.removeListener(listener);
}
void emit(ui.Image image) => setImage(ImageInfo(image: image));
}
// Real IO transport: count through body completion, not just response headers.
class _TrackingClient extends http.BaseClient {
final inner = IOClient(_RealHttpOverrides().createHttpClient(null));
int active = 0, peak = 0, started = 0, aborted = 0;
final retired = Completer<void>();
void recordAbort() {
aborted++;
if (aborted == 4) retired.complete();
}
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
started++;
active++;
if (active > peak) peak = active;
try {
final response = await inner.send(request);
return http.StreamedResponse(_body(response.stream), response.statusCode);
} catch (error) {
active--;
if (error is http.RequestAbortedException) recordAbort();
rethrow;
}
}
Stream<List<int>> _body(Stream<List<int>> stream) async* {
try {
await for (final chunk in stream) {
yield chunk;
}
} catch (error) {
if (error is http.RequestAbortedException) recordAbort();
rethrow;
} finally {
active--;
}
}
@override
void close() => inner.close();
}
class _RealHttpOverrides extends HttpOverrides {}