diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index 276a68eeb..7e59a9724 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -5,6 +5,7 @@ See the [Migration Guide][] for the complete breaking changes list.** ## Unreleased +- Fix `FormData.readAsBytes` excessive memory usage with large payloads by replacing the O(n²) `reduce`+spread approach with a pre-allocated `Uint8List`. - Fix request hanging indefinitely when async interceptor callbacks throw without calling the handler. - Fix `HttpException: Connection closed before full header was received` being reported as `DioExceptionType.unknown`. - Add `transformTimeout` to bound long-running response transformations, including background JSON decoding. diff --git a/dio/lib/src/form_data.dart b/dio/lib/src/form_data.dart index 382b5c9e6..2e350cc2c 100644 --- a/dio/lib/src/form_data.dart +++ b/dio/lib/src/form_data.dart @@ -201,10 +201,14 @@ class FormData { } /// Transform the entire FormData contents as a list of bytes asynchronously. - Future readAsBytes() { - return Future.sync( - () => finalize().reduce((a, b) => Uint8List.fromList([...a, ...b])), - ); + Future readAsBytes() async { + final result = Uint8List(length); + int offset = 0; + await for (final chunk in finalize()) { + result.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + return result; } /// Clones this finalized [FormData] so it can be re-sent, for example when diff --git a/dio/test/formdata_test.dart b/dio/test/formdata_test.dart index 429c96656..4987837c2 100644 --- a/dio/test/formdata_test.dart +++ b/dio/test/formdata_test.dart @@ -1,11 +1,33 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:test/test.dart'; import 'mock/adapters.dart'; +int _indexOfSubList(List bytes, List pattern, [int start = 0]) { + if (pattern.isEmpty) { + return start <= bytes.length ? start : -1; + } + + for (int i = start; i <= bytes.length - pattern.length; i++) { + bool matched = true; + for (int j = 0; j < pattern.length; j++) { + if (bytes[i + j] != pattern[j]) { + matched = false; + break; + } + } + if (matched) { + return i; + } + } + + return -1; +} + void main() async { group(FormData, () { test( @@ -267,6 +289,53 @@ void main() async { expect(result, contains('name="api[data][c]"')); }); + test('readAsBytes keeps chunked file payload complete', () async { + const chunks = 128; + const chunkSize = 1024; + final expectedFileLength = chunks * chunkSize; + final expectedPayload = Uint8List.fromList( + List.generate( + expectedFileLength, + (i) => (i ~/ chunkSize) % 256, + ), + ); + + final file = MultipartFile.fromStream( + () => Stream>.fromIterable( + List.generate( + chunks, + (i) => List.filled(chunkSize, i % 256), + ), + ), + expectedFileLength, + filename: 'chunked.bin', + ); + + final fd = FormData.fromMap({'file': file}); + final data = await fd.readAsBytes(); + final headerStart = _indexOfSubList( + data, + utf8.encode('filename="chunked.bin"'), + ); + final headerEnd = _indexOfSubList( + data, + utf8.encode('\r\n\r\n'), + headerStart, + ); + final payloadStart = headerEnd + 4; + final payloadEnd = payloadStart + expectedPayload.length; + final trailingBoundary = utf8.encode('\r\n--${fd.boundary}--\r\n'); + + expect(data.length, fd.length); + expect(headerStart, isNonNegative); + expect(headerEnd, isNonNegative); + expect( + data.sublist(payloadStart, payloadEnd), + orderedEquals(expectedPayload), + ); + expect(data.sublist(payloadEnd), orderedEquals(trailingBoundary)); + }); + test('posts maps correctly', () async { final fd = FormData.fromMap( {