mirror of
https://github.com/block/buzz.git
synced 2026-11-13 12:51:34 +00:00
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>
395 lines
13 KiB
Dart
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 {}
|