Skip chunked framing when contentLength is null (#1970)

* Skip chunked framing when contentLength is null

IOClient mapped a null BaseRequest.contentLength to dart:io's -1
sentinel, which forces Transfer-Encoding: chunked. For bodyless GET/HEAD
this overwrote dart:io's contentLength = 0 default and sent an empty
chunked body that can desync pooled keep-alive connections. Skip the
assignment when contentLength is null so the per-method default stands.

* Minor tweeks

---------

Co-authored-by: Brian Quinlan <bquinlan@google.com>
diff --git a/pkgs/http/CHANGELOG.md b/pkgs/http/CHANGELOG.md
index 5ff32e4..8b56ac4 100644
--- a/pkgs/http/CHANGELOG.md
+++ b/pkgs/http/CHANGELOG.md
@@ -8,6 +8,11 @@
 * Preserve header cases in `IOClient`.
 * Fix a [bug](https://github.com/dart-lang/http/issues/1934) to release the
   underlying connection when an `AbortableRequest` is aborted before its body is read.
+* Stop `IOClient` from sending bodyless GET/HEAD requests with
+  `Transfer-Encoding: chunked` when `BaseRequest.contentLength` is `null`;
+  `dart:io`'s per-method default framing is used instead. A GET or HEAD request
+  that streams a non-empty body while leaving `contentLength` `null` now throws
+  instead of being sent chunked.
 
 ## 1.6.0
 
diff --git a/pkgs/http/lib/src/io_client.dart b/pkgs/http/lib/src/io_client.dart
index a00ef9d..d8ec393 100644
--- a/pkgs/http/lib/src/io_client.dart
+++ b/pkgs/http/lib/src/io_client.dart
@@ -114,8 +114,13 @@
       var ioRequest = (await _inner!.openUrl(request.method, request.url))
         ..followRedirects = request.followRedirects
         ..maxRedirects = request.maxRedirects
-        ..contentLength = (request.contentLength ?? -1)
         ..persistentConnection = request.persistentConnection;
+      if (request.contentLength case final contentLength?) {
+        // Work around a Dart SDK issue where setting the contentLength to -1
+        // provokes the use of chunked transfer encoding. See:
+        // https://github.com/dart-lang/sdk/issues/60333
+        ioRequest.contentLength = contentLength;
+      }
       request.headers.forEach((name, value) {
         ioRequest.headers.set(name, value, preserveHeaderCase: true);
       });
diff --git a/pkgs/http/test/io/streamed_request_test.dart b/pkgs/http/test/io/streamed_request_test.dart
index f0e990c..ca506bf 100644
--- a/pkgs/http/test/io/streamed_request_test.dart
+++ b/pkgs/http/test/io/streamed_request_test.dart
@@ -40,6 +40,22 @@
       expect(await utf8.decodeStream(response.stream),
           parse(containsPair('headers', isNot(contains('content-length')))));
     });
+
+    test('streaming a non-empty body with null contentLength on GET throws',
+        () async {
+      var request = http.StreamedRequest('GET', serverUrl);
+      request.sink.add([1, 2, 3]);
+      unawaited(request.sink.close());
+      expect(request.send(), throwsClientException());
+    });
+
+    test('streaming a non-empty body with null contentLength on HEAD throws',
+        () async {
+      var request = http.StreamedRequest('HEAD', serverUrl);
+      request.sink.add([1, 2, 3]);
+      unawaited(request.sink.close());
+      expect(request.send(), throwsClientException());
+    });
   });
 
   // Regression test.
diff --git a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart
index 4a31783..345de28 100644
--- a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart
+++ b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart
@@ -5,6 +5,7 @@
 import 'package:http/http.dart';
 
 import 'src/abort_tests.dart';
+import 'src/bodyless_request_tests.dart';
 import 'src/close_tests.dart';
 import 'src/compressed_response_body_tests.dart';
 import 'src/isolate_test.dart';
@@ -24,6 +25,7 @@
 import 'src/server_errors_test.dart';
 
 export 'src/abort_tests.dart' show testAbort;
+export 'src/bodyless_request_tests.dart' show testBodylessRequests;
 export 'src/close_tests.dart' show testClose;
 export 'src/compressed_response_body_tests.dart'
     show testCompressedResponseBody;
@@ -100,6 +102,7 @@
   testRequestBody(clientFactory);
   testRequestBodyStreamed(clientFactory,
       canStreamRequestBody: canStreamRequestBody);
+  testBodylessRequests(clientFactory);
   testResponseBody(clientFactory, canStreamResponseBody: canStreamResponseBody);
   testResponseBodyStreamed(clientFactory,
       canStreamResponseBody: canStreamResponseBody);
diff --git a/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server.dart b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server.dart
new file mode 100644
index 0000000..be5f9d0
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server.dart
@@ -0,0 +1,110 @@
+// Copyright (c) 2026, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:stream_channel/stream_channel.dart';
+
+/// Starts an HTTP server that captures request headers and any body bytes
+/// transferred on the wire.
+///
+/// Channel protocol:
+///    On Startup:
+///     - send port
+///    On Request Received:
+///     - send Map with 'headers' (`Map<String, List<String>>`) and 'body'
+///       (`List<int>`)
+///    When Receive Anything:
+///     - exit
+void hybridMain(StreamChannel<Object?> channel) async {
+  late ServerSocket server;
+
+  server = (await ServerSocket.bind('localhost', 0))
+    ..listen((Socket socket) {
+      final buffer = BytesBuilder();
+      var responded = false;
+
+      socket.listen(
+        (data) {
+          buffer.add(data);
+          final bytes = buffer.toBytes();
+          final headerEnd = _findHeaderEnd(bytes);
+
+          if (headerEnd != -1 && !responded) {
+            final headerText = ascii.decode(bytes.sublist(0, headerEnd));
+            final requestLine = headerText.split('\r\n').first;
+            final method = requestLine.split(' ').first;
+
+            if (method == 'OPTIONS') {
+              responded = true;
+              socket.write(
+                'HTTP/1.1 200 OK\r\n'
+                'Access-Control-Allow-Origin: *\r\n'
+                'Access-Control-Allow-Methods: GET, HEAD, OPTIONS\r\n'
+                'Access-Control-Allow-Headers: *\r\n'
+                'Content-Length: 0\r\n\r\n',
+              );
+              unawaited(socket.close());
+              return;
+            }
+
+            responded = true;
+            final headers = _parseHeaders(headerText);
+            socket.write(
+              'HTTP/1.1 200 OK\r\n'
+              'Access-Control-Allow-Origin: *\r\n'
+              'Content-Length: 0\r\n\r\n',
+            );
+            Future.delayed(const Duration(milliseconds: 100), () {
+              final allBytes = buffer.toBytes();
+              final body = allBytes.sublist(headerEnd);
+              channel.sink.add({
+                'headers': headers,
+                'body': body.toList(),
+              });
+              unawaited(socket.close());
+            });
+          }
+        },
+        onError: (Object _) {},
+        cancelOnError: true,
+      );
+    });
+
+  channel.sink.add(server.port);
+  await channel
+      .stream.first; // Any writes indicates that the server should exit.
+  unawaited(server.close());
+}
+
+int _findHeaderEnd(List<int> bytes) {
+  for (var i = 0; i < bytes.length - 3; i++) {
+    if (bytes[i] == 13 &&
+        bytes[i + 1] == 10 &&
+        bytes[i + 2] == 13 &&
+        bytes[i + 3] == 10) {
+      return i + 4;
+    }
+  }
+  return -1;
+}
+
+Map<String, List<String>> _parseHeaders(String headerString) {
+  final lines = const LineSplitter().convert(headerString);
+  final headers = <String, List<String>>{};
+  for (var i = 1; i < lines.length; ++i) {
+    final line = lines[i];
+    if (line.isEmpty) break;
+    final colonIndex = line.indexOf(':');
+    if (colonIndex != -1) {
+      final key = line.substring(0, colonIndex).trim().toLowerCase();
+      final value = line.substring(colonIndex + 1).trim();
+      headers.putIfAbsent(key, () => []).add(value);
+    }
+  }
+  return headers;
+}
diff --git a/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_vm.dart b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_vm.dart
new file mode 100644
index 0000000..67e6e73
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_vm.dart
@@ -0,0 +1,14 @@
+// Generated by generate_server_wrappers.dart. Do not edit.
+
+import 'package:stream_channel/stream_channel.dart';
+
+import 'bodyless_request_server.dart';
+
+export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension;
+
+/// Starts the redirect test HTTP server in the same process.
+Future<StreamChannel<Object?>> startServer() async {
+  final controller = StreamChannelController<Object?>(sync: true);
+  hybridMain(controller.foreign);
+  return controller.local;
+}
diff --git a/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_web.dart b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_web.dart
new file mode 100644
index 0000000..bfd3b73
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_web.dart
@@ -0,0 +1,11 @@
+// Generated by generate_server_wrappers.dart. Do not edit.
+
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension;
+
+/// Starts the redirect test HTTP server out-of-process.
+Future<StreamChannel<Object?>> startServer() async => spawnHybridUri(Uri(
+    scheme: 'package',
+    path: 'http_client_conformance_tests/src/bodyless_request_server.dart'));
diff --git a/pkgs/http_client_conformance_tests/lib/src/bodyless_request_tests.dart b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_tests.dart
new file mode 100644
index 0000000..f98b7af
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_tests.dart
@@ -0,0 +1,78 @@
+// Copyright (c) 2026, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:async/async.dart';
+import 'package:http/http.dart';
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+import 'bodyless_request_server_vm.dart'
+    if (dart.library.js_interop) 'bodyless_request_server_web.dart';
+
+/// Tests that the [Client] correctly sends bodyless requests without framing
+/// headers (`Content-Length` or `Transfer-Encoding`) and without transferring
+/// body bytes on the wire.
+///
+/// RFC-9110 8.6 says:
+/// > A user agent SHOULD NOT send a Content-Length header field when the
+/// > request message does not contain content and the method semantics do not
+/// > anticipate such data.
+///
+/// RFC-9110 9.3.1 says:
+/// > A client SHOULD NOT generate content in a GET request unless it is made
+/// > directly to an origin server that has previously indicated, in or out of
+/// > band, that such a request has a purpose and will be adequately supported.
+void testBodylessRequests(Client Function() clientFactory) {
+  group('bodyless requests', () {
+    late Client client;
+    late String host;
+    late StreamChannel<Object?> httpServerChannel;
+    late StreamQueue<Object?> httpServerQueue;
+
+    setUp(() async {
+      client = clientFactory();
+      httpServerChannel = await startServer();
+      httpServerQueue = StreamQueue(httpServerChannel.stream);
+      host = 'localhost:${await httpServerQueue.nextAsInt}';
+    });
+    tearDown(() {
+      client.close();
+      httpServerChannel.sink.add(null);
+    });
+
+    test('client.send() with bodyless StreamedRequest GET', () async {
+      final request = StreamedRequest('GET', Uri.http(host, ''));
+      unawaited(request.sink.close());
+      final response = await client.send(request);
+      await response.stream.drain<void>();
+
+      final serverReceived = await httpServerQueue.next as Map;
+      final headers =
+          (serverReceived['headers'] as Map).cast<String, List<Object?>>();
+      final body = (serverReceived['body'] as List).cast<int>();
+
+      expect(headers.containsKey('transfer-encoding'), isFalse);
+      expect(headers.containsKey('content-length'), isFalse);
+      expect(body, isEmpty);
+    });
+
+    test('client.send() with bodyless StreamedRequest HEAD', () async {
+      final request = StreamedRequest('HEAD', Uri.http(host, ''));
+      unawaited(request.sink.close());
+      final response = await client.send(request);
+      await response.stream.drain<void>();
+
+      final serverReceived = await httpServerQueue.next as Map;
+      final headers =
+          (serverReceived['headers'] as Map).cast<String, List<Object?>>();
+      final body = (serverReceived['body'] as List).cast<int>();
+
+      expect(headers.containsKey('transfer-encoding'), isFalse);
+      expect(headers.containsKey('content-length'), isFalse);
+      expect(body, isEmpty);
+    });
+  });
+}