From e9da3645705db0e562f19b82e63cf0203bcc4c95 Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sat, 27 Jun 2026 10:17:09 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Fix=20`FormData.readAs?= =?UTF-8?q?Bytes`=20excessive=20memory=20usage=20with=20large=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the O(n²) `reduce`+spread approach with a single pre-allocated `Uint8List` and `setRange` writes. Since `FormData.length` is already known before finalization, we allocate the exact result buffer upfront and stream each chunk directly into it — no intermediate copies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dio/CHANGELOG.md | 2 ++ dio/lib/src/form_data.dart | 12 +++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index 276a68eeb..800c033c4 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -5,6 +5,8 @@ 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 `BytesBuilder`. + - 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..bb281fd2e 100644 --- a/dio/lib/src/form_data.dart +++ b/dio/lib/src/form_data.dart @@ -202,9 +202,15 @@ 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])), - ); + return Future.sync(() async { + final result = Uint8List(length); + var 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 From 711ca2f8f231ac6f0ff5e3db5cbc0ed4a3b285ce Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sat, 27 Jun 2026 11:26:50 +0800 Subject: [PATCH 2/3] test: add chunked readAsBytes coverage and tidy changelog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dio/CHANGELOG.md | 3 +-- dio/test/formdata_test.dart | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index 800c033c4..7e59a9724 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -5,8 +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 `BytesBuilder`. - +- 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/test/formdata_test.dart b/dio/test/formdata_test.dart index 429c96656..55e249527 100644 --- a/dio/test/formdata_test.dart +++ b/dio/test/formdata_test.dart @@ -267,6 +267,31 @@ 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 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 body = utf8.decode(data, allowMalformed: true); + + expect(data.length, fd.length); + expect(body, contains('filename="chunked.bin"')); + expect(body, contains('content-type: application/octet-stream')); + }); + test('posts maps correctly', () async { final fd = FormData.fromMap( { From 8a8f47b79c0dfd27dab4dc4abac64c8c19a4b5dc Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sun, 28 Jun 2026 20:59:34 +0800 Subject: [PATCH 3/3] test: strengthen FormData readAsBytes payload coverage --- dio/lib/src/form_data.dart | 18 ++++++------- dio/test/formdata_test.dart | 50 ++++++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/dio/lib/src/form_data.dart b/dio/lib/src/form_data.dart index bb281fd2e..2e350cc2c 100644 --- a/dio/lib/src/form_data.dart +++ b/dio/lib/src/form_data.dart @@ -201,16 +201,14 @@ class FormData { } /// Transform the entire FormData contents as a list of bytes asynchronously. - Future readAsBytes() { - return Future.sync(() async { - final result = Uint8List(length); - var offset = 0; - await for (final chunk in finalize()) { - result.setRange(offset, offset + chunk.length, chunk); - offset += chunk.length; - } - return result; - }); + 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 55e249527..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( @@ -271,6 +293,12 @@ void main() 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( @@ -285,11 +313,27 @@ void main() async { final fd = FormData.fromMap({'file': file}); final data = await fd.readAsBytes(); - final body = utf8.decode(data, allowMalformed: true); + 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(body, contains('filename="chunked.bin"')); - expect(body, contains('content-type: application/octet-stream')); + expect(headerStart, isNonNegative); + expect(headerEnd, isNonNegative); + expect( + data.sublist(payloadStart, payloadEnd), + orderedEquals(expectedPayload), + ); + expect(data.sublist(payloadEnd), orderedEquals(trailingBoundary)); }); test('posts maps correctly', () async {